diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 99412ae198..8a72cc2c27 100644 --- a/services/web/app/views/project/editor.pug +++ b/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( diff --git a/services/web/frontend/js/features/chat/components/chat-pane.js b/services/web/frontend/js/features/chat/components/chat-pane.js index 995cecf96f..ad529dda24 100644 --- a/services/web/frontend/js/features/chat/components/chat-pane.js +++ b/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 }) { @@ -88,9 +93,4 @@ function Placeholder() { ) } -ChatPane.propTypes = { - resetUnreadMessages: PropTypes.func.isRequired, - chatIsOpen: PropTypes.bool -} - export default withErrorBoundary(ChatPane) diff --git a/services/web/frontend/js/features/chat/context/chat-context.js b/services/web/frontend/js/features/chat/context/chat-context.js new file mode 100644 index 0000000000..30f173f595 --- /dev/null +++ b/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 {children} +} + +ChatProvider.propTypes = { + children: PropTypes.any +} + +export function useChatContext() { + return useContext(ChatContext) +} diff --git a/services/web/frontend/js/features/chat/store/chat-store-effect.js b/services/web/frontend/js/features/chat/store/chat-store-effect.js deleted file mode 100644 index bccf1bee8d..0000000000 --- a/services/web/frontend/js/features/chat/store/chat-store-effect.js +++ /dev/null @@ -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 - } -} diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js new file mode 100644 index 0000000000..bae69680f5 --- /dev/null +++ b/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 + + + {hasUnreadMessages ? ( + {unreadMessageCount} + ) : null} +

{t('chat')}

+
+ ) +} + +ChatToggleButton.propTypes = { + chatIsOpen: PropTypes.bool, + unreadMessageCount: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired +} + +export default ChatToggleButton diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js index 6ed5c30238..5515bab410 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js +++ b/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 : ` 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} /> ) } diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js index 340e2046d0..dc97613fdc 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js +++ b/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 (
@@ -12,13 +19,23 @@ function ToolbarHeader({ cobranding, onShowLeftMenuClick }) { {cobranding ? : null}
+
+ +
) } 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 diff --git a/services/web/frontend/js/infrastructure/browser-window-hook.js b/services/web/frontend/js/infrastructure/browser-window-hook.js new file mode 100644 index 0000000000..e1b6491ace --- /dev/null +++ b/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 diff --git a/services/web/frontend/js/shared/context/controllers/root-context-controller.js b/services/web/frontend/js/shared/context/controllers/root-context-controller.js index 8125b3aac2..06dd328149 100644 --- a/services/web/frontend/js/shared/context/controllers/root-context-controller.js +++ b/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' + ]) ) diff --git a/services/web/frontend/js/shared/context/editor-context.js b/services/web/frontend/js/shared/context/editor-context.js index 5f5f8abad8..73b4e0ca30 100644 --- a/services/web/frontend/js/shared/context/editor-context.js +++ b/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() { diff --git a/services/web/frontend/js/shared/context/root-context.js b/services/web/frontend/js/shared/context/root-context.js index 18ae2a7e0b..ae6e378071 100644 --- a/services/web/frontend/js/shared/context/root-context.js +++ b/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 ( - {children} + + {children} + ) } ContextRoot.propTypes = { children: PropTypes.any, - editorLoading: PropTypes.bool + editorLoading: PropTypes.bool, + chatIsOpenAngular: PropTypes.bool, + setChatIsOpenAngular: PropTypes.func.isRequired } export const rootContext = createSharedContext(ContextRoot) diff --git a/services/web/test/frontend/features/chat/components/chat-pane.test.js b/services/web/test/frontend/features/chat/components/chat-pane.test.js index d84d58fef4..fcd0a92d8e 100644 --- a/services/web/test/frontend/features/chat/components/chat-pane.test.js +++ b/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('', function() { ] beforeEach(function() { + global.localStorage.clear() stubChatStore({ user: currentUser }) stubUIConfig() stubMathJax() @@ -53,9 +54,7 @@ describe('', 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( - {}} chatIsOpen /> - ) + const { unmount } = renderWithChatContext() await screen.findByText('a message') await screen.findByText('another message') @@ -64,9 +63,7 @@ describe('', function() { it('A loading spinner is rendered while the messages are loading, then disappears', async function() { fetchMock.get(/messages/, []) - const { unmount } = renderWithEditorContext( - {}} chatIsOpen /> - ) + const { unmount } = renderWithChatContext() await waitForElementToBeRemoved(() => screen.getByText('Loading…')) unmount() }) @@ -74,18 +71,14 @@ describe('', function() { describe('"send your first message" placeholder', function() { it('is rendered when there are no messages ', async function() { fetchMock.get(/messages/, []) - const { unmount } = renderWithEditorContext( - {}} chatIsOpen /> - ) + const { unmount } = renderWithChatContext() 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( - {}} chatIsOpen /> - ) + const { unmount } = renderWithChatContext() expect( screen.queryByText('Send your first message to your collaborators') ).to.not.exist diff --git a/services/web/test/frontend/features/chat/components/message-list.test.js b/services/web/test/frontend/features/chat/components/message-list.test.js index f61ad0ebfb..0cdb5b664b 100644 --- a/services/web/test/frontend/features/chat/components/message-list.test.js +++ b/services/web/test/frontend/features/chat/components/message-list.test.js @@ -23,13 +23,13 @@ describe('', 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() diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js index 26e4fecded..a83057af94 100644 --- a/services/web/test/frontend/helpers/render-with-context.js +++ b/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( - {children} + {}} setChatIsOpenAngular={() => {}}> + {children} + ) } + +export function renderWithChatContext(children, { user, projectId } = {}) { + global.localStorage.setItem('editor.ui.chat.open', true) + return renderWithEditorContext({children}, { + user, + projectId + }) +}