chat-context.tsx 9.6 KB

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