chat-context.jsx 7.4 KB

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