chat-context.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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 '../../../infrastructure/browser-window-hook'
  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 'ERROR':
  67. return {
  68. ...state,
  69. status: 'error',
  70. error: action.error,
  71. }
  72. default:
  73. throw new Error('Unknown action')
  74. }
  75. }
  76. const initialState = {
  77. status: 'idle',
  78. messages: [],
  79. initialMessagesLoaded: false,
  80. lastTimestamp: null,
  81. atEnd: false,
  82. messageWasJustSent: 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. }).isRequired,
  99. }
  100. export function ChatProvider({ children }) {
  101. const { user } = useApplicationContext({
  102. user: PropTypes.shape({ id: PropTypes.string.isRequired }.isRequired),
  103. })
  104. const { projectId } = useEditorContext({
  105. projectId: PropTypes.string.isRequired,
  106. })
  107. const { chatIsOpen } = useLayoutContext({ chatIsOpen: PropTypes.bool })
  108. const {
  109. hasFocus: windowHasFocus,
  110. flashTitle,
  111. stopFlashingTitle,
  112. } = useBrowserWindow()
  113. const [state, dispatch] = useReducer(chatReducer, initialState)
  114. const { loadInitialMessages, loadMoreMessages } = useMemo(() => {
  115. function fetchMessages() {
  116. if (state.atEnd) return
  117. const query = { limit: PAGE_SIZE }
  118. if (state.lastTimestamp) {
  119. query.before = state.lastTimestamp
  120. }
  121. const queryString = new URLSearchParams(query)
  122. const url = `/project/${projectId}/messages?${queryString.toString()}`
  123. getJSON(url).then((messages = []) => {
  124. dispatch({
  125. type: 'FETCH_MESSAGES_SUCCESS',
  126. messages: messages.reverse(),
  127. })
  128. })
  129. }
  130. function loadInitialMessages() {
  131. if (state.initialMessagesLoaded) return
  132. dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
  133. fetchMessages()
  134. }
  135. function loadMoreMessages() {
  136. dispatch({ type: 'FETCH_MESSAGES' })
  137. fetchMessages()
  138. }
  139. return {
  140. loadInitialMessages,
  141. loadMoreMessages,
  142. }
  143. }, [projectId, state.atEnd, state.initialMessagesLoaded, state.lastTimestamp])
  144. const sendMessage = useCallback(
  145. content => {
  146. if (!content) return
  147. dispatch({
  148. type: 'SEND_MESSAGE',
  149. user,
  150. content,
  151. })
  152. const url = `/project/${projectId}/messages`
  153. postJSON(url, {
  154. body: { content },
  155. })
  156. },
  157. [projectId, user]
  158. )
  159. const markMessagesAsRead = useCallback(() => {
  160. dispatch({ type: 'MARK_MESSAGES_AS_READ' })
  161. }, [])
  162. // Handling receiving messages over the socket
  163. const socket = window._ide?.socket
  164. useEffect(() => {
  165. if (!socket) return
  166. function receivedMessage(message) {
  167. // If the message is from the current user and they just sent a message,
  168. // then we are receiving the sent message back from the socket. Ignore it
  169. // to prevent double message
  170. const messageIsFromSelf = message?.user?.id === user.id
  171. if (messageIsFromSelf && state.messageWasJustSent) return
  172. dispatch({ type: 'RECEIVE_MESSAGE', message })
  173. // Temporary workaround to pass state to unread message balloon in Angular
  174. window.dispatchEvent(
  175. new CustomEvent('Chat.MessageReceived', { detail: { message } })
  176. )
  177. }
  178. socket.on('new-chat-message', receivedMessage)
  179. return () => {
  180. if (!socket) return
  181. socket.removeListener('new-chat-message', receivedMessage)
  182. }
  183. // We're adding and removing the socket listener every time we send a
  184. // message (and messageWasJustSent changes). Not great, but no good way
  185. // around it
  186. }, [socket, state.messageWasJustSent, state.unreadMessageCount, user.id])
  187. // Handle unread messages
  188. useEffect(() => {
  189. if (windowHasFocus) {
  190. stopFlashingTitle()
  191. if (chatIsOpen) {
  192. markMessagesAsRead()
  193. }
  194. }
  195. if (!windowHasFocus && state.unreadMessageCount > 0) {
  196. flashTitle('New Message')
  197. }
  198. }, [
  199. windowHasFocus,
  200. chatIsOpen,
  201. state.unreadMessageCount,
  202. flashTitle,
  203. stopFlashingTitle,
  204. markMessagesAsRead,
  205. ])
  206. const value = {
  207. status: state.status,
  208. messages: state.messages,
  209. initialMessagesLoaded: state.initialMessagesLoaded,
  210. atEnd: state.atEnd,
  211. unreadMessageCount: state.unreadMessageCount,
  212. loadInitialMessages,
  213. loadMoreMessages,
  214. sendMessage,
  215. markMessagesAsRead,
  216. }
  217. return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
  218. }
  219. ChatProvider.propTypes = {
  220. children: PropTypes.any,
  221. }
  222. export function useChatContext(propTypes) {
  223. const data = useContext(ChatContext)
  224. PropTypes.checkPropTypes(propTypes, data, 'data', 'ChatContext.Provider')
  225. return data
  226. }