chat-context.tsx 9.6 KB

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