chat-context.tsx 9.1 KB

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