chat-context.tsx 8.0 KB

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