chat-context.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useReducer,
  7. useMemo,
  8. useRef,
  9. FC,
  10. } from 'react'
  11. import clientIdGenerator from '@/utils/client-id'
  12. import { useUserContext } from '../../../shared/context/user-context'
  13. import { useProjectContext } from '../../../shared/context/project-context'
  14. import {
  15. deleteJSON,
  16. getJSON,
  17. postJSON,
  18. } from '../../../infrastructure/fetch-json'
  19. import {
  20. appendMessage,
  21. confirmMessage,
  22. deleteMessage,
  23. editMessage,
  24. prependMessages,
  25. } from '../utils/message-list-utils'
  26. import useBrowserWindow from '../../../shared/hooks/use-browser-window'
  27. import { useLayoutContext } from '../../../shared/context/layout-context'
  28. import { useIdeContext } from '@/shared/context/ide-context'
  29. import getMeta from '@/utils/meta'
  30. import { debugConsole } from '@/utils/debugging'
  31. import { User } from '../../../../../types/user'
  32. import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
  33. import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
  34. const PAGE_SIZE = 50
  35. export type Message = {
  36. id: string
  37. timestamp: number
  38. content: string
  39. user?: User
  40. edited?: boolean
  41. deleted?: boolean
  42. pending?: boolean
  43. }
  44. export type ServerMessageEntry = Message & {
  45. content: string
  46. edited_at?: number
  47. }
  48. type State = {
  49. status: 'idle' | 'pending' | 'error'
  50. messages: Message[]
  51. initialMessagesLoaded: boolean
  52. lastTimestamp: number | null
  53. atEnd: boolean
  54. unreadMessageCount: number
  55. error?: Error | null
  56. uniqueMessageIds: string[]
  57. idOfMessageBeingEdited: Message['id'] | null
  58. }
  59. type Action =
  60. | {
  61. type: 'INITIAL_FETCH_MESSAGES'
  62. }
  63. | {
  64. type: 'FETCH_MESSAGES'
  65. }
  66. | {
  67. type: 'FETCH_MESSAGES_SUCCESS'
  68. messages: ServerMessageEntry[]
  69. }
  70. | {
  71. type: 'SEND_MESSAGE'
  72. user: any
  73. content: string
  74. }
  75. | {
  76. type: 'RECEIVE_MESSAGE'
  77. message: ServerMessageEntry
  78. }
  79. | {
  80. type: 'RECEIVE_OWN_MESSAGE'
  81. message: any
  82. }
  83. | {
  84. type: 'MARK_MESSAGES_AS_READ'
  85. }
  86. | {
  87. type: 'DELETE_MESSAGE'
  88. messageId: Message['id']
  89. }
  90. | {
  91. type: 'START_EDITING_MESSAGE'
  92. messageId: Message['id']
  93. }
  94. | {
  95. type: 'CANCEL_MESSAGE_EDIT'
  96. }
  97. | {
  98. type: 'RECEIVE_MESSAGE_EDIT'
  99. messageId: Message['id']
  100. content: string
  101. }
  102. | {
  103. type: 'CLEAR'
  104. }
  105. | {
  106. type: 'ERROR'
  107. error: any
  108. }
  109. let nextChatMessageId = 1
  110. function generateChatMessageId() {
  111. return '' + nextChatMessageId++
  112. }
  113. function chatReducer(state: State, action: Action): State {
  114. switch (action.type) {
  115. case 'INITIAL_FETCH_MESSAGES':
  116. return {
  117. ...state,
  118. status: 'pending',
  119. initialMessagesLoaded: true,
  120. }
  121. case 'FETCH_MESSAGES':
  122. return {
  123. ...state,
  124. status: 'pending',
  125. }
  126. case 'FETCH_MESSAGES_SUCCESS':
  127. return {
  128. ...state,
  129. status: 'idle',
  130. ...prependMessages(
  131. state.messages,
  132. action.messages,
  133. state.uniqueMessageIds
  134. ),
  135. lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
  136. atEnd: action.messages.length < PAGE_SIZE,
  137. }
  138. case 'SEND_MESSAGE':
  139. return {
  140. ...state,
  141. ...appendMessage(
  142. state.messages,
  143. {
  144. // Messages are sent optimistically, so don't have an id (used for
  145. // React keys). The id is valid for this session, and ensures all
  146. // messages have an id. It will be overwritten by the actual ids on
  147. // refresh
  148. id: generateChatMessageId(),
  149. user: action.user,
  150. content: action.content,
  151. timestamp: Date.now(),
  152. pending: true,
  153. },
  154. state.uniqueMessageIds
  155. ),
  156. }
  157. case 'RECEIVE_MESSAGE':
  158. return {
  159. ...state,
  160. ...appendMessage(
  161. state.messages,
  162. action.message,
  163. state.uniqueMessageIds
  164. ),
  165. unreadMessageCount: state.unreadMessageCount + 1,
  166. }
  167. case 'RECEIVE_OWN_MESSAGE':
  168. return {
  169. ...state,
  170. ...confirmMessage(action.message, state.messages),
  171. }
  172. case 'DELETE_MESSAGE':
  173. return {
  174. ...state,
  175. ...deleteMessage(action.messageId, state.messages),
  176. }
  177. case 'START_EDITING_MESSAGE':
  178. return {
  179. ...state,
  180. idOfMessageBeingEdited: action.messageId,
  181. }
  182. case 'CANCEL_MESSAGE_EDIT':
  183. return {
  184. ...state,
  185. idOfMessageBeingEdited: null,
  186. }
  187. case 'RECEIVE_MESSAGE_EDIT':
  188. return {
  189. ...state,
  190. ...editMessage(action.messageId, action.content, state.messages),
  191. }
  192. case 'MARK_MESSAGES_AS_READ':
  193. return {
  194. ...state,
  195. unreadMessageCount: 0,
  196. }
  197. case 'CLEAR':
  198. return { ...initialState }
  199. case 'ERROR':
  200. return {
  201. ...state,
  202. status: 'error',
  203. error: action.error,
  204. }
  205. default:
  206. throw new Error('Unknown action')
  207. }
  208. }
  209. const initialState: State = {
  210. status: 'idle',
  211. messages: [],
  212. initialMessagesLoaded: false,
  213. lastTimestamp: null,
  214. atEnd: false,
  215. unreadMessageCount: 0,
  216. error: null,
  217. uniqueMessageIds: [],
  218. idOfMessageBeingEdited: null,
  219. }
  220. export const ChatContext = createContext<
  221. | {
  222. status: 'idle' | 'pending' | 'error'
  223. messages: Message[]
  224. initialMessagesLoaded: boolean
  225. atEnd: boolean
  226. unreadMessageCount: number
  227. idOfMessageBeingEdited: State['idOfMessageBeingEdited']
  228. loadInitialMessages: () => void
  229. loadMoreMessages: () => void
  230. sendMessage: (message: any) => void
  231. markMessagesAsRead: () => void
  232. deleteMessage: (messageId: Message['id']) => void
  233. startedEditingMessage: (messageId: Message['id']) => void
  234. cancelMessageEdit: () => void
  235. editMessage: (messageId: Message['id'], content: string) => void
  236. reset: () => void
  237. error?: Error | null
  238. }
  239. | undefined
  240. >(undefined)
  241. export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
  242. const chatEnabled = getMeta('ol-capabilities')?.includes('chat')
  243. const clientId = useRef<string>()
  244. if (clientId.current === undefined) {
  245. clientId.current = clientIdGenerator.get()
  246. }
  247. const user = useUserContext()
  248. const { projectId } = useProjectContext()
  249. const { chatIsOpen: chatIsOpenOldEditor } = useLayoutContext()
  250. const { selectedTab: selectedRailTab, isOpen: railIsOpen } = useRailContext()
  251. const newEditor = useIsNewEditorEnabled()
  252. const chatIsOpen = newEditor
  253. ? selectedRailTab === 'chat' && railIsOpen
  254. : chatIsOpenOldEditor
  255. const {
  256. hasFocus: windowHasFocus,
  257. flashTitle,
  258. stopFlashingTitle,
  259. } = useBrowserWindow()
  260. const [state, dispatch] = useReducer(chatReducer, initialState)
  261. const { loadInitialMessages, loadMoreMessages, reset } = useMemo(() => {
  262. function fetchMessages() {
  263. if (state.atEnd) return
  264. const query: Record<string, string> = {
  265. limit: String(PAGE_SIZE),
  266. }
  267. if (state.lastTimestamp) {
  268. query.before = String(state.lastTimestamp)
  269. }
  270. const queryString = new URLSearchParams(query)
  271. const url = `/project/${projectId}/messages?${queryString.toString()}`
  272. getJSON(url)
  273. .then((messages = []) => {
  274. dispatch({
  275. type: 'FETCH_MESSAGES_SUCCESS',
  276. messages: messages.reverse(),
  277. })
  278. })
  279. .catch(error => {
  280. dispatch({
  281. type: 'ERROR',
  282. error,
  283. })
  284. })
  285. }
  286. function loadInitialMessages() {
  287. if (!chatEnabled) {
  288. debugConsole.warn(`chat is disabled, won't load initial messages`)
  289. return
  290. }
  291. if (state.initialMessagesLoaded) return
  292. dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
  293. fetchMessages()
  294. }
  295. function loadMoreMessages() {
  296. if (!chatEnabled) {
  297. debugConsole.warn(`chat is disabled, won't load messages`)
  298. return
  299. }
  300. dispatch({ type: 'FETCH_MESSAGES' })
  301. fetchMessages()
  302. }
  303. function reset() {
  304. if (!chatEnabled) {
  305. debugConsole.warn(`chat is disabled, won't reset chat`)
  306. return
  307. }
  308. dispatch({ type: 'CLEAR' })
  309. fetchMessages()
  310. }
  311. return {
  312. loadInitialMessages,
  313. loadMoreMessages,
  314. reset,
  315. }
  316. }, [
  317. chatEnabled,
  318. projectId,
  319. state.atEnd,
  320. state.initialMessagesLoaded,
  321. state.lastTimestamp,
  322. ])
  323. const sendMessage = useCallback(
  324. (content: string) => {
  325. if (!chatEnabled) {
  326. debugConsole.warn(`chat is disabled, won't send message`)
  327. return
  328. }
  329. if (!content) return
  330. dispatch({
  331. type: 'SEND_MESSAGE',
  332. user,
  333. content,
  334. })
  335. const url = `/project/${projectId}/messages`
  336. postJSON(url, {
  337. body: { content, client_id: clientId.current },
  338. }).catch(error => {
  339. dispatch({
  340. type: 'ERROR',
  341. error,
  342. })
  343. })
  344. },
  345. [chatEnabled, projectId, user]
  346. )
  347. const startedEditingMessage = useCallback(
  348. (messageId: Message['id']) => {
  349. if (!chatEnabled) {
  350. debugConsole.warn(`chat is disabled, won't send message`)
  351. return
  352. }
  353. dispatch({
  354. type: 'START_EDITING_MESSAGE',
  355. messageId,
  356. })
  357. },
  358. [chatEnabled]
  359. )
  360. const cancelMessageEdit = useCallback(() => {
  361. if (!chatEnabled) {
  362. debugConsole.warn(`chat is disabled, won't cancel message edit`)
  363. return
  364. }
  365. dispatch({
  366. type: 'CANCEL_MESSAGE_EDIT',
  367. })
  368. }, [chatEnabled])
  369. const markMessagesAsRead = useCallback(() => {
  370. if (!chatEnabled) {
  371. debugConsole.warn(`chat is disabled, won't mark messages as read`)
  372. return
  373. }
  374. dispatch({ type: 'MARK_MESSAGES_AS_READ' })
  375. }, [chatEnabled])
  376. const deleteMessage = useCallback(
  377. (messageId: Message['id']) => {
  378. if (!chatEnabled) {
  379. debugConsole.warn(`chat is disabled, won't delete message`)
  380. return
  381. }
  382. if (!messageId) return
  383. dispatch({
  384. type: 'DELETE_MESSAGE',
  385. messageId,
  386. })
  387. deleteJSON(`/project/${projectId}/messages/${messageId}`).catch(error => {
  388. dispatch({
  389. type: 'ERROR',
  390. error,
  391. })
  392. })
  393. },
  394. [chatEnabled, projectId]
  395. )
  396. const editMessage = useCallback(
  397. (messageId: Message['id'], content: string) => {
  398. if (!chatEnabled) {
  399. debugConsole.warn(`chat is disabled, won't edit message`)
  400. return
  401. }
  402. if (!messageId || !content) return
  403. dispatch({
  404. type: 'RECEIVE_MESSAGE_EDIT',
  405. messageId,
  406. content,
  407. })
  408. dispatch({
  409. type: 'CANCEL_MESSAGE_EDIT',
  410. })
  411. postJSON(`/project/${projectId}/messages/${messageId}/edit`, {
  412. body: { content },
  413. }).catch(error => {
  414. dispatch({
  415. type: 'ERROR',
  416. error,
  417. })
  418. })
  419. },
  420. [chatEnabled, projectId]
  421. )
  422. // Handling receiving and deleting messages over the socket
  423. const { socket } = useIdeContext()
  424. useEffect(() => {
  425. if (!chatEnabled || !socket) return
  426. function receivedMessage(message: any) {
  427. // If the message is from the current client id, then we are receiving the
  428. // sent message back from the socket. In this case, we want to update the
  429. // message in our local state with the ID of the message on the server.
  430. // Ignore it to prevent double message.
  431. if (message.clientId === clientId.current) {
  432. dispatch({ type: 'RECEIVE_OWN_MESSAGE', message })
  433. } else {
  434. dispatch({ type: 'RECEIVE_MESSAGE', message })
  435. }
  436. }
  437. function deletedMessage(message: {
  438. messageId: Message['id']
  439. userId: User['id']
  440. }) {
  441. if (message.userId === user.id) return
  442. dispatch({
  443. type: 'DELETE_MESSAGE',
  444. messageId: message.messageId,
  445. })
  446. }
  447. function editedMessage(message: {
  448. messageId: Message['id']
  449. userId: User['id']
  450. content: string
  451. }) {
  452. if (message.userId === user.id) return
  453. dispatch({
  454. type: 'RECEIVE_MESSAGE_EDIT',
  455. messageId: message.messageId,
  456. content: message.content,
  457. })
  458. }
  459. socket.on('new-chat-message', receivedMessage)
  460. socket.on('delete-global-message', deletedMessage)
  461. socket.on('edit-global-message', editedMessage)
  462. return () => {
  463. if (!socket) return
  464. socket.removeListener('new-chat-message', receivedMessage)
  465. socket.removeListener('delete-global-message', deletedMessage)
  466. socket.removeListener('edit-global-message', editedMessage)
  467. }
  468. }, [chatEnabled, socket, user.id])
  469. // Handle unread messages
  470. useEffect(() => {
  471. if (windowHasFocus) {
  472. stopFlashingTitle()
  473. if (chatIsOpen) {
  474. markMessagesAsRead()
  475. }
  476. }
  477. if (!windowHasFocus && state.unreadMessageCount > 0) {
  478. flashTitle('New Message')
  479. }
  480. }, [
  481. windowHasFocus,
  482. chatIsOpen,
  483. state.unreadMessageCount,
  484. flashTitle,
  485. stopFlashingTitle,
  486. markMessagesAsRead,
  487. ])
  488. const value = useMemo(
  489. () => ({
  490. status: state.status,
  491. messages: state.messages,
  492. initialMessagesLoaded: state.initialMessagesLoaded,
  493. atEnd: state.atEnd,
  494. unreadMessageCount: state.unreadMessageCount,
  495. idOfMessageBeingEdited: state.idOfMessageBeingEdited,
  496. loadInitialMessages,
  497. loadMoreMessages,
  498. reset,
  499. sendMessage,
  500. markMessagesAsRead,
  501. deleteMessage,
  502. startedEditingMessage,
  503. cancelMessageEdit,
  504. editMessage,
  505. error: state.error,
  506. }),
  507. [
  508. loadInitialMessages,
  509. loadMoreMessages,
  510. markMessagesAsRead,
  511. deleteMessage,
  512. startedEditingMessage,
  513. cancelMessageEdit,
  514. editMessage,
  515. reset,
  516. sendMessage,
  517. state.atEnd,
  518. state.error,
  519. state.initialMessagesLoaded,
  520. state.messages,
  521. state.status,
  522. state.unreadMessageCount,
  523. state.idOfMessageBeingEdited,
  524. ]
  525. )
  526. return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
  527. }
  528. export function useChatContext() {
  529. const context = useContext(ChatContext)
  530. if (!context) {
  531. throw new Error('useChatContext is only available inside ChatProvider')
  532. }
  533. return context
  534. }