Просмотр исходного кода

[ReactNavToolbar] Chat Toggle Button + `chat-context` (#3625)

* Added toggle chat button to navigation header

* new `useBrowserWindow` hook to work with browser title and focus

* react2angular chat toggle button plumbing

GitOrigin-RevId: 4380f1db9c7cc9a25bfb8d7a33e18d61b1d32993
Miguel Serrano 5 лет назад
Родитель
Сommit
260b878b7d

+ 5 - 1
services/web/app/views/project/editor.pug

@@ -11,7 +11,11 @@ block content
 		div(
 			ng-controller="ReactRootContextController"
 		)
-			shared-context-react(editor-loading="editorLoading")
+			shared-context-react(
+				editor-loading="editorLoading"
+				chat-is-open-angular="chatIsOpenAngular"
+				set-chat-is-open-angular="setChatIsOpenAngular"
+			)
 		.loading-screen(ng-if="state.loading")
 			.loading-screen-brand-container
 				.loading-screen-brand(

+ 12 - 12
services/web/frontend/js/features/chat/components/chat-pane.js

@@ -1,24 +1,29 @@
 import React, { useEffect, useState } from 'react'
-import PropTypes from 'prop-types'
 import MessageList from './message-list'
 import MessageInput from './message-input'
 import InfiniteScroll from './infinite-scroll'
 import Icon from '../../../shared/components/icon'
 import { useTranslation } from 'react-i18next'
-import { useChatStore } from '../store/chat-store-effect'
+import { useEditorContext } from '../../../shared/context/editor-context'
 import withErrorBoundary from '../../../infrastructure/error-boundary'
+import { useChatContext } from '../context/chat-context'
 
-function ChatPane({ resetUnreadMessages, chatIsOpen }) {
+function ChatPane() {
   const { t } = useTranslation()
 
   const {
+    ui: { chatIsOpen }
+  } = useEditorContext()
+
+  const {
+    userId,
     atEnd,
     loading,
     loadMoreMessages,
     messages,
     sendMessage,
-    userId
-  } = useChatStore()
+    resetUnreadMessageCount
+  } = useChatContext()
 
   const [initialMessagesLoaded, setInitialMessagesLoaded] = useState(false)
 
@@ -52,12 +57,12 @@ function ChatPane({ resetUnreadMessages, chatIsOpen }) {
           <MessageList
             messages={messages}
             userId={userId}
-            resetUnreadMessages={resetUnreadMessages}
+            resetUnreadMessages={resetUnreadMessageCount}
           />
         </div>
       </InfiniteScroll>
       <MessageInput
-        resetUnreadMessages={resetUnreadMessages}
+        resetUnreadMessages={resetUnreadMessageCount}
         sendMessage={sendMessage}
       />
     </aside>
@@ -88,9 +93,4 @@ function Placeholder() {
   )
 }
 
-ChatPane.propTypes = {
-  resetUnreadMessages: PropTypes.func.isRequired,
-  chatIsOpen: PropTypes.bool
-}
-
 export default withErrorBoundary(ChatPane)

+ 101 - 0
services/web/frontend/js/features/chat/context/chat-context.js

@@ -0,0 +1,101 @@
+import React, {
+  createContext,
+  useCallback,
+  useContext,
+  useState,
+  useEffect
+} from 'react'
+import PropTypes from 'prop-types'
+import { useApplicationContext } from '../../../shared/context/application-context'
+import { useEditorContext } from '../../../shared/context/editor-context'
+import { ChatStore } from '../store/chat-store'
+import useBrowserWindow from '../../../infrastructure/browser-window-hook'
+
+export const ChatContext = createContext()
+
+export function ChatProvider({ children }) {
+  const {
+    hasFocus: windowHasFocus,
+    flashTitle,
+    stopFlashingTitle
+  } = useBrowserWindow()
+  const { user } = useApplicationContext()
+  const {
+    projectId,
+    ui: { chatIsOpen }
+  } = useEditorContext()
+
+  const [unreadMessageCount, setUnreadMessageCount] = useState(0)
+  function resetUnreadMessageCount() {
+    setUnreadMessageCount(0)
+  }
+
+  const [atEnd, setAtEnd] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [messages, setMessages] = useState([])
+
+  const [store] = useState(() => new ChatStore(user, projectId))
+
+  useEffect(() => {
+    if (windowHasFocus) {
+      stopFlashingTitle()
+      if (chatIsOpen) {
+        setUnreadMessageCount(0)
+      }
+    }
+    if (!windowHasFocus && unreadMessageCount > 0) {
+      flashTitle('New Message')
+    }
+  }, [
+    windowHasFocus,
+    chatIsOpen,
+    unreadMessageCount,
+    flashTitle,
+    stopFlashingTitle
+  ])
+
+  useEffect(() => {
+    function updateState() {
+      setAtEnd(store.atEnd)
+      setLoading(store.loading)
+      setMessages(store.messages)
+    }
+
+    function handleNewMessage() {
+      setUnreadMessageCount(prevCount => prevCount + 1)
+    }
+
+    store.on('updated', updateState)
+    store.on('message-received', handleNewMessage)
+
+    updateState()
+
+    return () => store.destroy()
+  }, [store])
+
+  const loadMoreMessages = useCallback(() => store.loadMoreMessages(), [store])
+  const sendMessage = useCallback(message => store.sendMessage(message), [
+    store
+  ])
+
+  const value = {
+    userId: user.id,
+    atEnd,
+    loading,
+    messages,
+    unreadMessageCount,
+    resetUnreadMessageCount,
+    loadMoreMessages,
+    sendMessage
+  }
+
+  return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
+}
+
+ChatProvider.propTypes = {
+  children: PropTypes.any
+}
+
+export function useChatContext() {
+  return useContext(ChatContext)
+}

+ 0 - 38
services/web/frontend/js/features/chat/store/chat-store-effect.js

@@ -1,38 +0,0 @@
-import { useState, useCallback, useEffect } from 'react'
-import { ChatStore } from './chat-store'
-import { useApplicationContext } from '../../../shared/context/application-context'
-import { useEditorContext } from '../../../shared/context/editor-context'
-
-export function useChatStore() {
-  const { user } = useApplicationContext()
-  const { projectId } = useEditorContext()
-
-  const [atEnd, setAtEnd] = useState(false)
-  const [loading, setLoading] = useState(false)
-  const [messages, setMessages] = useState([])
-
-  const [store] = useState(() => new ChatStore(user, projectId))
-  const loadMoreMessages = useCallback(() => store.loadMoreMessages(), [store])
-  const sendMessage = useCallback(message => store.sendMessage(message), [
-    store
-  ])
-
-  useEffect(() => {
-    function handleStoreUpdated() {
-      setAtEnd(store.atEnd)
-      setLoading(store.loading)
-      setMessages(store.messages)
-    }
-    store.on('updated', handleStoreUpdated)
-    return () => store.destroy()
-  }, [store])
-
-  return {
-    userId: user.id,
-    atEnd,
-    loading,
-    messages,
-    loadMoreMessages,
-    sendMessage
-  }
-}

+ 40 - 0
services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js

@@ -0,0 +1,40 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../shared/components/icon'
+
+function ChatToggleButton({ chatIsOpen, unreadMessageCount, onClick }) {
+  const { t } = useTranslation()
+  const classes = classNames(
+    'btn',
+    'btn-full-height',
+    'btn-full-height-no-border',
+    { active: chatIsOpen }
+  )
+
+  const hasUnreadMessages = unreadMessageCount > 0
+
+  return (
+    // eslint-disable-next-line jsx-a11y/anchor-is-valid
+    <a role="button" className={classes} href="#" onClick={onClick}>
+      <Icon
+        type="fw"
+        modifier="comment"
+        classes={{ icon: hasUnreadMessages ? 'bounce' : undefined }}
+      />
+      {hasUnreadMessages ? (
+        <span className="label label-info">{unreadMessageCount}</span>
+      ) : null}
+      <p className="toolbar-label">{t('chat')}</p>
+    </a>
+  )
+}
+
+ChatToggleButton.propTypes = {
+  chatIsOpen: PropTypes.bool,
+  unreadMessageCount: PropTypes.number.isRequired,
+  onClick: PropTypes.func.isRequired
+}
+
+export default ChatToggleButton

+ 14 - 2
services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js

@@ -1,10 +1,19 @@
-import React from 'react'
+import React, { useCallback } from 'react'
 import PropTypes from 'prop-types'
 import ToolbarHeader from './toolbar-header'
 import { useEditorContext } from '../../../shared/context/editor-context'
+import { useChatContext } from '../../chat/context/chat-context'
 
 function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
-  const { cobranding, loading } = useEditorContext()
+  const { cobranding, loading, ui } = useEditorContext()
+  const { resetUnreadMessageCount, unreadMessageCount } = useChatContext()
+
+  const toggleChatOpen = useCallback(() => {
+    if (!ui.chatIsOpen) {
+      resetUnreadMessageCount()
+    }
+    ui.toggleChatOpen()
+  }, [ui, resetUnreadMessageCount])
 
   // using {display: 'none'} as 1:1 migration from Angular's ng-hide. Using
   // `loading ? null : <ToolbarHeader/>` causes UI glitches
@@ -13,6 +22,9 @@ function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
       style={loading ? { display: 'none' } : {}}
       cobranding={cobranding}
       onShowLeftMenuClick={onShowLeftMenuClick}
+      chatIsOpen={ui.chatIsOpen}
+      unreadMessageCount={unreadMessageCount}
+      toggleChatOpen={toggleChatOpen}
     />
   )
 }

+ 19 - 2
services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js

@@ -3,8 +3,15 @@ import PropTypes from 'prop-types'
 import MenuButton from './menu-button'
 import CobrandingLogo from './cobranding-logo'
 import BackToProjectsButton from './back-to-projects-button'
+import ChatToggleButton from './chat-toggle-button'
 
-function ToolbarHeader({ cobranding, onShowLeftMenuClick }) {
+function ToolbarHeader({
+  cobranding,
+  onShowLeftMenuClick,
+  chatIsOpen,
+  toggleChatOpen,
+  unreadMessageCount
+}) {
   return (
     <header className="toolbar toolbar-header toolbar-with-labels">
       <div className="toolbar-left">
@@ -12,13 +19,23 @@ function ToolbarHeader({ cobranding, onShowLeftMenuClick }) {
         {cobranding ? <CobrandingLogo {...cobranding} /> : null}
         <BackToProjectsButton />
       </div>
+      <div className="toolbar-right">
+        <ChatToggleButton
+          chatIsOpen={chatIsOpen}
+          onClick={toggleChatOpen}
+          unreadMessageCount={unreadMessageCount}
+        />
+      </div>
     </header>
   )
 }
 
 ToolbarHeader.propTypes = {
   onShowLeftMenuClick: PropTypes.func.isRequired,
-  cobranding: PropTypes.object
+  cobranding: PropTypes.object,
+  chatIsOpen: PropTypes.bool,
+  toggleChatOpen: PropTypes.func.isRequired,
+  unreadMessageCount: PropTypes.number.isRequired
 }
 
 export default ToolbarHeader

+ 60 - 0
services/web/frontend/js/infrastructure/browser-window-hook.js

@@ -0,0 +1,60 @@
+import { useEffect, useState } from 'react'
+
+let titleIsFlashing = false
+let originalTitle
+let flashIntervalHandle
+
+export function flashTitle(message) {
+  if (document.hasFocus() || titleIsFlashing) {
+    return
+  }
+
+  function swapTitle() {
+    if (window.document.title === originalTitle) {
+      window.document.title = message
+    } else {
+      window.document.title = originalTitle
+    }
+  }
+
+  originalTitle = window.document.title
+  window.document.title = message
+  titleIsFlashing = true
+  flashIntervalHandle = setInterval(swapTitle, 800)
+}
+
+export function stopFlashingTitle() {
+  if (!titleIsFlashing) {
+    return
+  }
+
+  clearInterval(flashIntervalHandle)
+  window.document.title = originalTitle
+  originalTitle = undefined
+  titleIsFlashing = false
+}
+
+function useBrowserWindow() {
+  const [hasFocus, setHasFocus] = useState(document.hasFocus())
+
+  useEffect(() => {
+    function handleFocusEvent() {
+      setHasFocus(true)
+    }
+
+    function handleBlurEvent() {
+      setHasFocus(false)
+    }
+
+    window.addEventListener('focus', handleFocusEvent)
+    window.addEventListener('blur', handleBlurEvent)
+    return () => {
+      window.removeEventListener('focus', handleFocusEvent)
+      window.removeEventListener('blur', handleBlurEvent)
+    }
+  }, [])
+
+  return { hasFocus, flashTitle, stopFlashingTitle }
+}
+
+export default useBrowserWindow

+ 21 - 1
services/web/frontend/js/shared/context/controllers/root-context-controller.js

@@ -7,9 +7,29 @@ App.controller('ReactRootContextController', function($scope, ide) {
   ide.$scope.$watch('state.loading', editorLoading => {
     $scope.editorLoading = editorLoading
   })
+
+  $scope.setChatIsOpenAngular = value => {
+    ide.$scope.$applyAsync(() => {
+      ide.$scope.ui.chatOpen = value
+    })
+  }
+
+  // we need to pass `$scope.ui.chatOpen` to Angular while both React and Angular
+  // Navigation Toolbars exist in the codebase. Once the Angular version is removed,
+  // the React Navigation Toolbar will be the only source of truth for the open/closed state,
+  // but `setChatIsOpenAngular` will still need to exist since Angular is responsible of the
+  // global layout
+  $scope.chatIsOpenAngular = ide.$scope.ui.chatOpen
+  ide.$scope.$watch('ui.chatOpen', value => {
+    $scope.chatIsOpenAngular = value
+  })
 })
 
 App.component(
   'sharedContextReact',
-  react2angular(rootContext.component, ['editorLoading'])
+  react2angular(rootContext.component, [
+    'editorLoading',
+    'setChatIsOpenAngular',
+    'chatIsOpenAngular'
+  ])
 )

+ 38 - 4
services/web/frontend/js/shared/context/editor-context.js

@@ -1,9 +1,15 @@
-import React, { createContext, useContext } from 'react'
+import React, { createContext, useCallback, useContext, useEffect } from 'react'
 import PropTypes from 'prop-types'
+import usePersistedState from '../../infrastructure/persisted-state-hook'
 
 export const EditorContext = createContext()
 
-export function EditorProvider({ children, loading }) {
+export function EditorProvider({
+  children,
+  loading,
+  chatIsOpenAngular,
+  setChatIsOpenAngular
+}) {
   const cobranding = window.brandVariation
     ? {
         logoImgUrl: window.brandVariation.logo_url,
@@ -17,11 +23,37 @@ export function EditorProvider({ children, loading }) {
       ? window._ide.$scope.project.owner._id
       : null
 
+  const [chatIsOpen, setChatIsOpen] = usePersistedState(
+    'editor.ui.chat.open',
+    false
+  )
+
+  const toggleChatOpen = useCallback(() => {
+    setChatIsOpen(!chatIsOpen)
+    setChatIsOpenAngular(!chatIsOpen)
+  }, [chatIsOpen, setChatIsOpenAngular, setChatIsOpen])
+
+  // updates React's `chatIsOpen` state when the chat is opened by Angular.
+  // In order to prevent race conditions with `toggleChatOpen` it's not a 1:1 binding:
+  // Angular forces the React state to `true`, but can only set it to `false` when
+  // the React state is explicitly `true`.
+  useEffect(() => {
+    if (chatIsOpenAngular) {
+      setChatIsOpen(true)
+    } else if (chatIsOpen) {
+      setChatIsOpen(false)
+    }
+  }, [chatIsOpenAngular, chatIsOpen, setChatIsOpen])
+
   const editorContextValue = {
     cobranding,
     loading,
     projectId: window.project_id,
-    isProjectOwner: ownerId === window.user.id
+    isProjectOwner: ownerId === window.user.id,
+    ui: {
+      chatIsOpen,
+      toggleChatOpen
+    }
   }
 
   return (
@@ -33,7 +65,9 @@ export function EditorProvider({ children, loading }) {
 
 EditorProvider.propTypes = {
   children: PropTypes.any,
-  loading: PropTypes.bool
+  loading: PropTypes.bool,
+  chatIsOpenAngular: PropTypes.bool,
+  setChatIsOpenAngular: PropTypes.func.isRequired
 }
 
 export function useEditorContext() {

+ 17 - 3
services/web/frontend/js/shared/context/root-context.js

@@ -3,18 +3,32 @@ import PropTypes from 'prop-types'
 import { ApplicationProvider } from './application-context'
 import { EditorProvider } from './editor-context'
 import createSharedContext from 'react2angular-shared-context'
+import { ChatProvider } from '../../features/chat/context/chat-context'
 
-export function ContextRoot({ children, editorLoading }) {
+export function ContextRoot({
+  children,
+  editorLoading,
+  chatIsOpenAngular,
+  setChatIsOpenAngular
+}) {
   return (
     <ApplicationProvider>
-      <EditorProvider loading={editorLoading}>{children}</EditorProvider>
+      <EditorProvider
+        loading={editorLoading}
+        chatIsOpenAngular={chatIsOpenAngular}
+        setChatIsOpenAngular={setChatIsOpenAngular}
+      >
+        <ChatProvider>{children}</ChatProvider>
+      </EditorProvider>
     </ApplicationProvider>
   )
 }
 
 ContextRoot.propTypes = {
   children: PropTypes.any,
-  editorLoading: PropTypes.bool
+  editorLoading: PropTypes.bool,
+  chatIsOpenAngular: PropTypes.bool,
+  setChatIsOpenAngular: PropTypes.func.isRequired
 }
 
 export const rootContext = createSharedContext(ContextRoot)

+ 6 - 13
services/web/test/frontend/features/chat/components/chat-pane.test.js

@@ -4,7 +4,7 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 
 import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
+import { renderWithChatContext } from '../../../helpers/render-with-context'
 import {
   stubChatStore,
   stubMathJax,
@@ -37,6 +37,7 @@ describe('<ChatPane />', function() {
   ]
 
   beforeEach(function() {
+    global.localStorage.clear()
     stubChatStore({ user: currentUser })
     stubUIConfig()
     stubMathJax()
@@ -53,9 +54,7 @@ describe('<ChatPane />', function() {
   it('renders multiple messages', async function() {
     fetchMock.get(/messages/, testMessages)
     // unmounting before `beforeEach` block is executed is required to prevent cleanup errors
-    const { unmount } = renderWithEditorContext(
-      <ChatPane resetUnreadMessages={() => {}} chatIsOpen />
-    )
+    const { unmount } = renderWithChatContext(<ChatPane />)
 
     await screen.findByText('a message')
     await screen.findByText('another message')
@@ -64,9 +63,7 @@ describe('<ChatPane />', function() {
 
   it('A loading spinner is rendered while the messages are loading, then disappears', async function() {
     fetchMock.get(/messages/, [])
-    const { unmount } = renderWithEditorContext(
-      <ChatPane resetUnreadMessages={() => {}} chatIsOpen />
-    )
+    const { unmount } = renderWithChatContext(<ChatPane />)
     await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
     unmount()
   })
@@ -74,18 +71,14 @@ describe('<ChatPane />', function() {
   describe('"send your first message" placeholder', function() {
     it('is rendered when there are no messages ', async function() {
       fetchMock.get(/messages/, [])
-      const { unmount } = renderWithEditorContext(
-        <ChatPane resetUnreadMessages={() => {}} chatIsOpen />
-      )
+      const { unmount } = renderWithChatContext(<ChatPane />)
       await screen.findByText('Send your first message to your collaborators')
       unmount()
     })
 
     it('is not rendered when messages are displayed', function() {
       fetchMock.get(/messages/, testMessages)
-      const { unmount } = renderWithEditorContext(
-        <ChatPane resetUnreadMessages={() => {}} chatIsOpen />
-      )
+      const { unmount } = renderWithChatContext(<ChatPane />)
       expect(
         screen.queryByText('Send your first message to your collaborators')
       ).to.not.exist

+ 2 - 2
services/web/test/frontend/features/chat/components/message-list.test.js

@@ -23,13 +23,13 @@ describe('<MessageList />', function() {
   function createMessages() {
     return [
       {
-        id: 'test_msg_1',
+        id: '1',
         contents: ['a message'],
         user: currentUser,
         timestamp: new Date().getTime()
       },
       {
-        id: 'test_msg_2',
+        id: '2',
         contents: ['another message'],
         user: currentUser,
         timestamp: new Date().getTime()

+ 12 - 1
services/web/test/frontend/helpers/render-with-context.js

@@ -3,6 +3,7 @@ import { render } from '@testing-library/react'
 import { ApplicationProvider } from '../../../frontend/js/shared/context/application-context'
 import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
 import sinon from 'sinon'
+import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-context'
 
 export function renderWithEditorContext(
   children,
@@ -25,7 +26,17 @@ export function renderWithEditorContext(
   }
   return render(
     <ApplicationProvider>
-      <EditorProvider>{children}</EditorProvider>
+      <EditorProvider setChatIsOpen={() => {}} setChatIsOpenAngular={() => {}}>
+        {children}
+      </EditorProvider>
     </ApplicationProvider>
   )
 }
+
+export function renderWithChatContext(children, { user, projectId } = {}) {
+  global.localStorage.setItem('editor.ui.chat.open', true)
+  return renderWithEditorContext(<ChatProvider>{children}</ChatProvider>, {
+    user,
+    projectId
+  })
+}