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