chat-context.tsx 9.7 KB

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