[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
This commit is contained in:
Miguel Serrano 2021-02-09 16:37:48 +01:00 committed by Copybot
parent 7f6d439302
commit 260b878b7d
14 changed files with 347 additions and 79 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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}
/>
)
}

View file

@ -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

View file

@ -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

View file

@ -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'
])
)

View file

@ -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() {

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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
})
}