chat-context.js 7.8 KB

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