chat-context.tsx 9.1 KB

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