chat-context.tsx 14 KB

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