chat-context.js 7.2 KB

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