chat-context.jsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useReducer,
  7. useMemo,
  8. useRef,
  9. } from 'react'
  10. import PropTypes from 'prop-types'
  11. import { v4 as uuid } from 'uuid'
  12. import { useUserContext } from '../../../shared/context/user-context'
  13. import { useProjectContext } from '../../../shared/context/project-context'
  14. import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
  15. import { appendMessage, prependMessages } from '../utils/message-list-appender'
  16. import useBrowserWindow from '../../../shared/hooks/use-browser-window'
  17. import { useLayoutContext } from '../../../shared/context/layout-context'
  18. import { useIdeContext } from '@/shared/context/ide-context'
  19. const PAGE_SIZE = 50
  20. export function chatReducer(state, action) {
  21. switch (action.type) {
  22. case 'INITIAL_FETCH_MESSAGES':
  23. return {
  24. ...state,
  25. status: 'pending',
  26. initialMessagesLoaded: true,
  27. }
  28. case 'FETCH_MESSAGES':
  29. return {
  30. ...state,
  31. status: 'pending',
  32. }
  33. case 'FETCH_MESSAGES_SUCCESS':
  34. return {
  35. ...state,
  36. status: 'idle',
  37. ...prependMessages(
  38. state.messages,
  39. action.messages,
  40. state.uniqueMessageIds
  41. ),
  42. lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
  43. atEnd: action.messages.length < PAGE_SIZE,
  44. }
  45. case 'SEND_MESSAGE':
  46. return {
  47. ...state,
  48. ...appendMessage(
  49. state.messages,
  50. {
  51. // Messages are sent optimistically, so don't have an id (used for
  52. // React keys). The uuid is valid for this session, and ensures all
  53. // messages have an id. It will be overwritten by the actual ids on
  54. // refresh
  55. id: uuid(),
  56. user: action.user,
  57. content: action.content,
  58. timestamp: Date.now(),
  59. },
  60. state.uniqueMessageIds
  61. ),
  62. }
  63. case 'RECEIVE_MESSAGE':
  64. return {
  65. ...state,
  66. ...appendMessage(
  67. state.messages,
  68. action.message,
  69. state.uniqueMessageIds
  70. ),
  71. unreadMessageCount: state.unreadMessageCount + 1,
  72. }
  73. case 'MARK_MESSAGES_AS_READ':
  74. return {
  75. ...state,
  76. unreadMessageCount: 0,
  77. }
  78. case 'CLEAR':
  79. return { ...initialState }
  80. case 'ERROR':
  81. return {
  82. ...state,
  83. status: 'error',
  84. error: action.error,
  85. }
  86. default:
  87. throw new Error('Unknown action')
  88. }
  89. }
  90. const initialState = {
  91. status: 'idle',
  92. messages: [],
  93. initialMessagesLoaded: false,
  94. lastTimestamp: null,
  95. atEnd: false,
  96. unreadMessageCount: 0,
  97. error: null,
  98. uniqueMessageIds: [],
  99. }
  100. export const ChatContext = createContext()
  101. ChatContext.Provider.propTypes = {
  102. value: PropTypes.shape({
  103. status: PropTypes.string.isRequired,
  104. messages: PropTypes.array.isRequired,
  105. initialMessagesLoaded: PropTypes.bool.isRequired,
  106. atEnd: PropTypes.bool.isRequired,
  107. unreadMessageCount: PropTypes.number.isRequired,
  108. loadInitialMessages: PropTypes.func.isRequired,
  109. loadMoreMessages: PropTypes.func.isRequired,
  110. sendMessage: PropTypes.func.isRequired,
  111. markMessagesAsRead: PropTypes.func.isRequired,
  112. reset: PropTypes.func.isRequired,
  113. error: PropTypes.object,
  114. }).isRequired,
  115. }
  116. export function ChatProvider({ children }) {
  117. const clientId = useRef(uuid())
  118. const user = useUserContext({
  119. id: PropTypes.string.isRequired,
  120. })
  121. const { _id: projectId } = useProjectContext({
  122. _id: PropTypes.string.isRequired,
  123. })
  124. const { chatIsOpen } = useLayoutContext()
  125. const {
  126. hasFocus: windowHasFocus,
  127. flashTitle,
  128. stopFlashingTitle,
  129. } = useBrowserWindow()
  130. const [state, dispatch] = useReducer(chatReducer, initialState)
  131. const { loadInitialMessages, loadMoreMessages, reset } = useMemo(() => {
  132. function fetchMessages() {
  133. if (state.atEnd) return
  134. const query = { limit: PAGE_SIZE }
  135. if (state.lastTimestamp) {
  136. query.before = state.lastTimestamp
  137. }
  138. const queryString = new URLSearchParams(query)
  139. const url = `/project/${projectId}/messages?${queryString.toString()}`
  140. getJSON(url)
  141. .then((messages = []) => {
  142. dispatch({
  143. type: 'FETCH_MESSAGES_SUCCESS',
  144. messages: messages.reverse(),
  145. })
  146. })
  147. .catch(error => {
  148. dispatch({
  149. type: 'ERROR',
  150. error,
  151. })
  152. })
  153. }
  154. function loadInitialMessages() {
  155. if (state.initialMessagesLoaded) return
  156. dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
  157. fetchMessages()
  158. }
  159. function loadMoreMessages() {
  160. dispatch({ type: 'FETCH_MESSAGES' })
  161. fetchMessages()
  162. }
  163. function reset() {
  164. dispatch({ type: 'CLEAR' })
  165. fetchMessages()
  166. }
  167. return {
  168. loadInitialMessages,
  169. loadMoreMessages,
  170. reset,
  171. }
  172. }, [projectId, state.atEnd, state.initialMessagesLoaded, state.lastTimestamp])
  173. const sendMessage = useCallback(
  174. content => {
  175. if (!content) return
  176. dispatch({
  177. type: 'SEND_MESSAGE',
  178. user,
  179. content,
  180. })
  181. const url = `/project/${projectId}/messages`
  182. postJSON(url, {
  183. body: { content, client_id: clientId.current },
  184. }).catch(error => {
  185. dispatch({
  186. type: 'ERROR',
  187. error,
  188. })
  189. })
  190. },
  191. [projectId, user]
  192. )
  193. const markMessagesAsRead = useCallback(() => {
  194. dispatch({ type: 'MARK_MESSAGES_AS_READ' })
  195. }, [])
  196. // Handling receiving messages over the socket
  197. const { socket } = useIdeContext()
  198. useEffect(() => {
  199. if (!socket) return
  200. function receivedMessage(message) {
  201. // If the message is from the current client id, then we are receiving the sent message back from the socket.
  202. // Ignore it to prevent double message.
  203. if (message.clientId === clientId.current) return
  204. dispatch({ type: 'RECEIVE_MESSAGE', message })
  205. }
  206. socket.on('new-chat-message', receivedMessage)
  207. return () => {
  208. if (!socket) return
  209. socket.removeListener('new-chat-message', receivedMessage)
  210. }
  211. }, [socket])
  212. // Handle unread messages
  213. useEffect(() => {
  214. if (windowHasFocus) {
  215. stopFlashingTitle()
  216. if (chatIsOpen) {
  217. markMessagesAsRead()
  218. }
  219. }
  220. if (!windowHasFocus && state.unreadMessageCount > 0) {
  221. flashTitle('New Message')
  222. }
  223. }, [
  224. windowHasFocus,
  225. chatIsOpen,
  226. state.unreadMessageCount,
  227. flashTitle,
  228. stopFlashingTitle,
  229. markMessagesAsRead,
  230. ])
  231. const value = useMemo(
  232. () => ({
  233. status: state.status,
  234. messages: state.messages,
  235. initialMessagesLoaded: state.initialMessagesLoaded,
  236. atEnd: state.atEnd,
  237. unreadMessageCount: state.unreadMessageCount,
  238. loadInitialMessages,
  239. loadMoreMessages,
  240. reset,
  241. sendMessage,
  242. markMessagesAsRead,
  243. error: state.error,
  244. }),
  245. [
  246. loadInitialMessages,
  247. loadMoreMessages,
  248. markMessagesAsRead,
  249. reset,
  250. sendMessage,
  251. state.atEnd,
  252. state.error,
  253. state.initialMessagesLoaded,
  254. state.messages,
  255. state.status,
  256. state.unreadMessageCount,
  257. ]
  258. )
  259. return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
  260. }
  261. ChatProvider.propTypes = {
  262. children: PropTypes.any,
  263. }
  264. export function useChatContext(propTypes) {
  265. const data = useContext(ChatContext)
  266. PropTypes.checkPropTypes(propTypes, data, 'data', 'ChatContext.Provider')
  267. return data
  268. }