chat-context.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useReducer,
  7. useMemo,
  8. } from 'react'
  9. import PropTypes from 'prop-types'
  10. import { v4 as uuid } from 'uuid'
  11. import { useUserContext } from '../../../shared/context/user-context'
  12. import { useProjectContext } from '../../../shared/context/project-context'
  13. import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
  14. import { appendMessage, prependMessages } from '../utils/message-list-appender'
  15. import useBrowserWindow from '../../../shared/hooks/use-browser-window'
  16. import { useLayoutContext } from '../../../shared/context/layout-context'
  17. const PAGE_SIZE = 50
  18. const clientId = uuid()
  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 user = useUserContext({
  104. id: PropTypes.string.isRequired,
  105. })
  106. const { _id: projectId } = useProjectContext({
  107. _id: PropTypes.string.isRequired,
  108. })
  109. const { chatIsOpen } = useLayoutContext({ chatIsOpen: PropTypes.bool })
  110. const {
  111. hasFocus: windowHasFocus,
  112. flashTitle,
  113. stopFlashingTitle,
  114. } = useBrowserWindow()
  115. const [state, dispatch] = useReducer(chatReducer, initialState)
  116. const { loadInitialMessages, loadMoreMessages, reset } = useMemo(() => {
  117. function fetchMessages() {
  118. if (state.atEnd) return
  119. const query = { limit: PAGE_SIZE }
  120. if (state.lastTimestamp) {
  121. query.before = state.lastTimestamp
  122. }
  123. const queryString = new URLSearchParams(query)
  124. const url = `/project/${projectId}/messages?${queryString.toString()}`
  125. getJSON(url)
  126. .then((messages = []) => {
  127. dispatch({
  128. type: 'FETCH_MESSAGES_SUCCESS',
  129. messages: messages.reverse(),
  130. })
  131. })
  132. .catch(error => {
  133. dispatch({
  134. type: 'ERROR',
  135. error,
  136. })
  137. })
  138. }
  139. function loadInitialMessages() {
  140. if (state.initialMessagesLoaded) return
  141. dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
  142. fetchMessages()
  143. }
  144. function loadMoreMessages() {
  145. dispatch({ type: 'FETCH_MESSAGES' })
  146. fetchMessages()
  147. }
  148. function reset() {
  149. dispatch({ type: 'CLEAR' })
  150. fetchMessages()
  151. }
  152. return {
  153. loadInitialMessages,
  154. loadMoreMessages,
  155. reset,
  156. }
  157. }, [projectId, state.atEnd, state.initialMessagesLoaded, state.lastTimestamp])
  158. const sendMessage = useCallback(
  159. content => {
  160. if (!content) return
  161. dispatch({
  162. type: 'SEND_MESSAGE',
  163. user,
  164. content,
  165. })
  166. const url = `/project/${projectId}/messages`
  167. postJSON(url, {
  168. body: { content, client_id: clientId },
  169. }).catch(error => {
  170. dispatch({
  171. type: 'ERROR',
  172. error,
  173. })
  174. })
  175. },
  176. [projectId, user]
  177. )
  178. const markMessagesAsRead = useCallback(() => {
  179. dispatch({ type: 'MARK_MESSAGES_AS_READ' })
  180. }, [])
  181. // Handling receiving messages over the socket
  182. const socket = window._ide?.socket
  183. useEffect(() => {
  184. if (!socket) return
  185. function receivedMessage(message) {
  186. // If the message is from the current client id, then we are receiving the sent message back from the socket.
  187. // Ignore it to prevent double message.
  188. if (message.clientId === clientId) return
  189. dispatch({ type: 'RECEIVE_MESSAGE', message })
  190. }
  191. socket.on('new-chat-message', receivedMessage)
  192. return () => {
  193. if (!socket) return
  194. socket.removeListener('new-chat-message', receivedMessage)
  195. }
  196. }, [socket])
  197. // Handle unread messages
  198. useEffect(() => {
  199. if (windowHasFocus) {
  200. stopFlashingTitle()
  201. if (chatIsOpen) {
  202. markMessagesAsRead()
  203. }
  204. }
  205. if (!windowHasFocus && state.unreadMessageCount > 0) {
  206. flashTitle('New Message')
  207. }
  208. }, [
  209. windowHasFocus,
  210. chatIsOpen,
  211. state.unreadMessageCount,
  212. flashTitle,
  213. stopFlashingTitle,
  214. markMessagesAsRead,
  215. ])
  216. const value = useMemo(
  217. () => ({
  218. status: state.status,
  219. messages: state.messages,
  220. initialMessagesLoaded: state.initialMessagesLoaded,
  221. atEnd: state.atEnd,
  222. unreadMessageCount: state.unreadMessageCount,
  223. loadInitialMessages,
  224. loadMoreMessages,
  225. reset,
  226. sendMessage,
  227. markMessagesAsRead,
  228. error: state.error,
  229. }),
  230. [
  231. loadInitialMessages,
  232. loadMoreMessages,
  233. markMessagesAsRead,
  234. reset,
  235. sendMessage,
  236. state.atEnd,
  237. state.error,
  238. state.initialMessagesLoaded,
  239. state.messages,
  240. state.status,
  241. state.unreadMessageCount,
  242. ]
  243. )
  244. return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
  245. }
  246. ChatProvider.propTypes = {
  247. children: PropTypes.any,
  248. }
  249. export function useChatContext(propTypes) {
  250. const data = useContext(ChatContext)
  251. PropTypes.checkPropTypes(propTypes, data, 'data', 'ChatContext.Provider')
  252. return data
  253. }