chat-context.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useReducer,
  7. useMemo,
  8. useRef,
  9. FC,
  10. } from 'react'
  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. import { useIdeContext } from '@/shared/context/ide-context'
  19. const PAGE_SIZE = 50
  20. export type Message = {
  21. id: string
  22. timestamp: number
  23. contents: string
  24. }
  25. type State = {
  26. status: 'idle' | 'pending' | 'error'
  27. messages: Message[]
  28. initialMessagesLoaded: boolean
  29. lastTimestamp: number | null
  30. atEnd: boolean
  31. unreadMessageCount: number
  32. error?: Error | null
  33. uniqueMessageIds: string[]
  34. }
  35. type Action =
  36. | {
  37. type: 'INITIAL_FETCH_MESSAGES'
  38. }
  39. | {
  40. type: 'FETCH_MESSAGES'
  41. }
  42. | {
  43. type: 'FETCH_MESSAGES_SUCCESS'
  44. messages: Message[]
  45. }
  46. | {
  47. type: 'SEND_MESSAGE'
  48. user: any
  49. content: any
  50. }
  51. | {
  52. type: 'RECEIVE_MESSAGE'
  53. message: any
  54. }
  55. | {
  56. type: 'MARK_MESSAGES_AS_READ'
  57. }
  58. | {
  59. type: 'CLEAR'
  60. }
  61. | {
  62. type: 'ERROR'
  63. error: any
  64. }
  65. // Wrap uuid in an object method so that it can be stubbed
  66. export const chatClientIdGenerator = {
  67. generate: () => uuid(),
  68. }
  69. let nextChatMessageId = 1
  70. function generateChatMessageId() {
  71. return '' + nextChatMessageId++
  72. }
  73. function chatReducer(state: State, action: Action): State {
  74. switch (action.type) {
  75. case 'INITIAL_FETCH_MESSAGES':
  76. return {
  77. ...state,
  78. status: 'pending',
  79. initialMessagesLoaded: true,
  80. }
  81. case 'FETCH_MESSAGES':
  82. return {
  83. ...state,
  84. status: 'pending',
  85. }
  86. case 'FETCH_MESSAGES_SUCCESS':
  87. return {
  88. ...state,
  89. status: 'idle',
  90. ...prependMessages(
  91. state.messages,
  92. action.messages,
  93. state.uniqueMessageIds
  94. ),
  95. lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
  96. atEnd: action.messages.length < PAGE_SIZE,
  97. }
  98. case 'SEND_MESSAGE':
  99. return {
  100. ...state,
  101. ...appendMessage(
  102. state.messages,
  103. {
  104. // Messages are sent optimistically, so don't have an id (used for
  105. // React keys). The id is valid for this session, and ensures all
  106. // messages have an id. It will be overwritten by the actual ids on
  107. // refresh
  108. id: generateChatMessageId(),
  109. user: action.user,
  110. content: action.content,
  111. timestamp: Date.now(),
  112. },
  113. state.uniqueMessageIds
  114. ),
  115. }
  116. case 'RECEIVE_MESSAGE':
  117. return {
  118. ...state,
  119. ...appendMessage(
  120. state.messages,
  121. action.message,
  122. state.uniqueMessageIds
  123. ),
  124. unreadMessageCount: state.unreadMessageCount + 1,
  125. }
  126. case 'MARK_MESSAGES_AS_READ':
  127. return {
  128. ...state,
  129. unreadMessageCount: 0,
  130. }
  131. case 'CLEAR':
  132. return { ...initialState }
  133. case 'ERROR':
  134. return {
  135. ...state,
  136. status: 'error',
  137. error: action.error,
  138. }
  139. default:
  140. throw new Error('Unknown action')
  141. }
  142. }
  143. const initialState: State = {
  144. status: 'idle',
  145. messages: [],
  146. initialMessagesLoaded: false,
  147. lastTimestamp: null,
  148. atEnd: false,
  149. unreadMessageCount: 0,
  150. error: null,
  151. uniqueMessageIds: [],
  152. }
  153. export const ChatContext = createContext<
  154. | {
  155. status: 'idle' | 'pending' | 'error'
  156. messages: Message[]
  157. initialMessagesLoaded: boolean
  158. atEnd: boolean
  159. unreadMessageCount: number
  160. loadInitialMessages: () => void
  161. loadMoreMessages: () => void
  162. sendMessage: (message: any) => void
  163. markMessagesAsRead: () => void
  164. reset: () => void
  165. error?: Error | null
  166. }
  167. | undefined
  168. >(undefined)
  169. export const ChatProvider: FC = ({ children }) => {
  170. const clientId = useRef<string>()
  171. if (clientId.current === undefined) {
  172. clientId.current = chatClientIdGenerator.generate()
  173. }
  174. const user = useUserContext()
  175. const { _id: projectId } = useProjectContext()
  176. const { chatIsOpen } = useLayoutContext()
  177. const {
  178. hasFocus: windowHasFocus,
  179. flashTitle,
  180. stopFlashingTitle,
  181. } = useBrowserWindow()
  182. const [state, dispatch] = useReducer(chatReducer, initialState)
  183. const { loadInitialMessages, loadMoreMessages, reset } = useMemo(() => {
  184. function fetchMessages() {
  185. if (state.atEnd) return
  186. const query: Record<string, string> = {
  187. limit: String(PAGE_SIZE),
  188. }
  189. if (state.lastTimestamp) {
  190. query.before = String(state.lastTimestamp)
  191. }
  192. const queryString = new URLSearchParams(query)
  193. const url = `/project/${projectId}/messages?${queryString.toString()}`
  194. getJSON(url)
  195. .then((messages = []) => {
  196. dispatch({
  197. type: 'FETCH_MESSAGES_SUCCESS',
  198. messages: messages.reverse(),
  199. })
  200. })
  201. .catch(error => {
  202. dispatch({
  203. type: 'ERROR',
  204. error,
  205. })
  206. })
  207. }
  208. function loadInitialMessages() {
  209. if (state.initialMessagesLoaded) return
  210. dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
  211. fetchMessages()
  212. }
  213. function loadMoreMessages() {
  214. dispatch({ type: 'FETCH_MESSAGES' })
  215. fetchMessages()
  216. }
  217. function reset() {
  218. dispatch({ type: 'CLEAR' })
  219. fetchMessages()
  220. }
  221. return {
  222. loadInitialMessages,
  223. loadMoreMessages,
  224. reset,
  225. }
  226. }, [projectId, state.atEnd, state.initialMessagesLoaded, state.lastTimestamp])
  227. const sendMessage = useCallback(
  228. content => {
  229. if (!content) return
  230. dispatch({
  231. type: 'SEND_MESSAGE',
  232. user,
  233. content,
  234. })
  235. const url = `/project/${projectId}/messages`
  236. postJSON(url, {
  237. body: { content, client_id: clientId.current },
  238. }).catch(error => {
  239. dispatch({
  240. type: 'ERROR',
  241. error,
  242. })
  243. })
  244. },
  245. [projectId, user]
  246. )
  247. const markMessagesAsRead = useCallback(() => {
  248. dispatch({ type: 'MARK_MESSAGES_AS_READ' })
  249. }, [])
  250. // Handling receiving messages over the socket
  251. const { socket } = useIdeContext()
  252. useEffect(() => {
  253. if (!socket) return
  254. function receivedMessage(message: any) {
  255. // If the message is from the current client id, then we are receiving the sent message back from the socket.
  256. // Ignore it to prevent double message.
  257. if (message.clientId === clientId.current) return
  258. dispatch({ type: 'RECEIVE_MESSAGE', message })
  259. }
  260. socket.on('new-chat-message', receivedMessage)
  261. return () => {
  262. if (!socket) return
  263. socket.removeListener('new-chat-message', receivedMessage)
  264. }
  265. }, [socket])
  266. // Handle unread messages
  267. useEffect(() => {
  268. if (windowHasFocus) {
  269. stopFlashingTitle()
  270. if (chatIsOpen) {
  271. markMessagesAsRead()
  272. }
  273. }
  274. if (!windowHasFocus && state.unreadMessageCount > 0) {
  275. flashTitle('New Message')
  276. }
  277. }, [
  278. windowHasFocus,
  279. chatIsOpen,
  280. state.unreadMessageCount,
  281. flashTitle,
  282. stopFlashingTitle,
  283. markMessagesAsRead,
  284. ])
  285. const value = useMemo(
  286. () => ({
  287. status: state.status,
  288. messages: state.messages,
  289. initialMessagesLoaded: state.initialMessagesLoaded,
  290. atEnd: state.atEnd,
  291. unreadMessageCount: state.unreadMessageCount,
  292. loadInitialMessages,
  293. loadMoreMessages,
  294. reset,
  295. sendMessage,
  296. markMessagesAsRead,
  297. error: state.error,
  298. }),
  299. [
  300. loadInitialMessages,
  301. loadMoreMessages,
  302. markMessagesAsRead,
  303. reset,
  304. sendMessage,
  305. state.atEnd,
  306. state.error,
  307. state.initialMessagesLoaded,
  308. state.messages,
  309. state.status,
  310. state.unreadMessageCount,
  311. ]
  312. )
  313. return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
  314. }
  315. export function useChatContext() {
  316. const context = useContext(ChatContext)
  317. if (!context) {
  318. throw new Error('useChatContext is only available inside ChatProvider')
  319. }
  320. return context
  321. }