chat-context.tsx 8.4 KB

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