Преглед изворни кода

Merge pull request #3632 from overleaf/msm-navbar-collaborator-widget

[ReactNavToolbar] Collaborators widget

GitOrigin-RevId: 65f2484962591103f02eb7624a974d0806b1abf0
Miguel Serrano пре 5 година
родитељ
комит
d78644e02c

+ 2 - 0
services/web/app/views/project/editor.pug

@@ -15,6 +15,8 @@ block content
 				editor-loading="editorLoading"
 				chat-is-open-angular="chatIsOpenAngular"
 				set-chat-is-open-angular="setChatIsOpenAngular"
+				open-doc="openDoc"
+				online-users-array="onlineUsersArray"
 			)
 		.loading-screen(ng-if="state.loading")
 			.loading-screen-brand-container

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -21,6 +21,7 @@
   "compile_mode": "",
   "compile_terminated_by_user": "",
   "compiling": "",
+  "connected_users": "",
   "conflicting_paths_found": "",
   "copy": "",
   "copy_project": "",

+ 15 - 1
services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js

@@ -5,7 +5,13 @@ import { useEditorContext } from '../../../shared/context/editor-context'
 import { useChatContext } from '../../chat/context/chat-context'
 
 function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
-  const { cobranding, loading, ui } = useEditorContext()
+  const {
+    cobranding,
+    loading,
+    ui,
+    onlineUsersArray,
+    openDoc
+  } = useEditorContext()
   const { resetUnreadMessageCount, unreadMessageCount } = useChatContext()
 
   const toggleChatOpen = useCallback(() => {
@@ -15,6 +21,12 @@ function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
     ui.toggleChatOpen()
   }, [ui, resetUnreadMessageCount])
 
+  function goToUser(user) {
+    if (user.doc && typeof user.row === 'number') {
+      openDoc(user.doc, { gotoLine: user.row + 1 })
+    }
+  }
+
   // using {display: 'none'} as 1:1 migration from Angular's ng-hide. Using
   // `loading ? null : <ToolbarHeader/>` causes UI glitches
   return (
@@ -25,6 +37,8 @@ function EditorNavigationToolbarRoot({ onShowLeftMenuClick }) {
       chatIsOpen={ui.chatIsOpen}
       unreadMessageCount={unreadMessageCount}
       toggleChatOpen={toggleChatOpen}
+      onlineUsers={onlineUsersArray}
+      goToUser={goToUser}
     />
   )
 }

+ 120 - 0
services/web/frontend/js/features/editor-navigation-toolbar/components/online-users-widget.js

@@ -0,0 +1,120 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { useTranslation } from 'react-i18next'
+import { Dropdown, MenuItem, OverlayTrigger, Tooltip } from 'react-bootstrap'
+import Icon from '../../../shared/components/icon'
+import ColorManager from '../../../ide/colors/ColorManager'
+
+function OnlineUsersWidget({ onlineUsers, goToUser }) {
+  const { t } = useTranslation()
+
+  const shouldDisplayDropdown = onlineUsers.length >= 4
+
+  if (shouldDisplayDropdown) {
+    return (
+      <Dropdown id="online-users" className="online-users" pullRight>
+        <DropDownToggleButton
+          bsRole="toggle"
+          onlineUserCount={onlineUsers.length}
+        />
+        <Dropdown.Menu>
+          <MenuItem header>{t('connected_users')}</MenuItem>
+          {onlineUsers.map(user => (
+            <MenuItem
+              as="button"
+              key={user.user_id}
+              eventKey={user}
+              onSelect={goToUser}
+            >
+              <UserIcon user={user} onClick={goToUser} showName />
+            </MenuItem>
+          ))}
+        </Dropdown.Menu>
+      </Dropdown>
+    )
+  } else {
+    return (
+      <div className="online-users">
+        {onlineUsers.map(user => (
+          <OverlayTrigger
+            key={user.user_id}
+            placement="bottom"
+            trigger={['hover', 'focus']}
+            overlay={<Tooltip id="tooltip-online-user">{user.name}</Tooltip>}
+          >
+            <span>
+              {/* OverlayTrigger won't fire unless UserIcon is wrapped in a span */}
+              <UserIcon user={user} onClick={goToUser} />
+            </span>
+          </OverlayTrigger>
+        ))}
+      </div>
+    )
+  }
+}
+
+OnlineUsersWidget.propTypes = {
+  onlineUsers: PropTypes.arrayOf(
+    PropTypes.shape({
+      user_id: PropTypes.string.isRequired,
+      name: PropTypes.string.isRequired
+    })
+  ).isRequired,
+  goToUser: PropTypes.func.isRequired
+}
+
+function UserIcon({ user, showName, onClick }) {
+  const backgroundColor = `hsl(${ColorManager.getHueForUserId(
+    user.user_id
+  )}, 70%, 50%)`
+
+  function handleOnClick() {
+    onClick(user)
+  }
+
+  return (
+    // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
+    <span onClick={handleOnClick}>
+      <span className="online-user" style={{ backgroundColor }}>
+        {user.name.slice(0, 1)}
+      </span>
+      {showName && user.name}
+    </span>
+  )
+}
+
+UserIcon.propTypes = {
+  user: PropTypes.shape({
+    user_id: PropTypes.string.isRequired,
+    name: PropTypes.string.isRequired
+  }),
+  showName: PropTypes.bool,
+  onClick: PropTypes.func.isRequired
+}
+
+const DropDownToggleButton = React.forwardRef((props, ref) => {
+  const { t } = useTranslation()
+  return (
+    <OverlayTrigger
+      placement="left"
+      overlay={
+        <Tooltip id="tooltip-connected-users">{t('connected_users')}</Tooltip>
+      }
+    >
+      <button
+        className="btn online-user online-user-multi"
+        onClick={props.onClick} // required by Bootstrap Dropdown to trigger an opening
+      >
+        <strong>{props.onlineUserCount}</strong>
+        <Icon type="users" modifier="fw" />
+      </button>
+    </OverlayTrigger>
+  )
+})
+
+DropDownToggleButton.propTypes = {
+  onlineUserCount: PropTypes.number.isRequired,
+  onClick: PropTypes.func
+}
+
+export default OnlineUsersWidget

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

@@ -4,13 +4,16 @@ import MenuButton from './menu-button'
 import CobrandingLogo from './cobranding-logo'
 import BackToProjectsButton from './back-to-projects-button'
 import ChatToggleButton from './chat-toggle-button'
+import OnlineUsersWidget from './online-users-widget'
 
 function ToolbarHeader({
   cobranding,
   onShowLeftMenuClick,
   chatIsOpen,
   toggleChatOpen,
-  unreadMessageCount
+  unreadMessageCount,
+  onlineUsers,
+  goToUser
 }) {
   return (
     <header className="toolbar toolbar-header toolbar-with-labels">
@@ -20,6 +23,7 @@ function ToolbarHeader({
         <BackToProjectsButton />
       </div>
       <div className="toolbar-right">
+        <OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
         <ChatToggleButton
           chatIsOpen={chatIsOpen}
           onClick={toggleChatOpen}
@@ -35,7 +39,9 @@ ToolbarHeader.propTypes = {
   cobranding: PropTypes.object,
   chatIsOpen: PropTypes.bool,
   toggleChatOpen: PropTypes.func.isRequired,
-  unreadMessageCount: PropTypes.number.isRequired
+  unreadMessageCount: PropTypes.number.isRequired,
+  onlineUsers: PropTypes.array.isRequired,
+  goToUser: PropTypes.func.isRequired
 }
 
 export default ToolbarHeader

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

@@ -23,6 +23,9 @@ App.controller('ReactRootContextController', function($scope, ide) {
   ide.$scope.$watch('ui.chatOpen', value => {
     $scope.chatIsOpenAngular = value
   })
+
+  // wrapper is required to avoid scope problems with `this` inside `EditorManager`
+  $scope.openDoc = (doc, args) => ide.editorManager.openDoc(doc, args)
 })
 
 App.component(
@@ -30,6 +33,11 @@ App.component(
   react2angular(rootContext.component, [
     'editorLoading',
     'setChatIsOpenAngular',
-    'chatIsOpenAngular'
+    'chatIsOpenAngular',
+    'openDoc',
+    // `$scope.onlineUsersArray` is already populated by `OnlineUsersManager`, which also creates
+    // a new array instance every time the list of online users change (which should refresh the
+    // value passed to React as a prop, triggering a re-render)
+    'onlineUsersArray'
   ])
 )

+ 8 - 2
services/web/frontend/js/shared/context/editor-context.js

@@ -21,7 +21,9 @@ export function EditorProvider({
   children,
   loading,
   chatIsOpenAngular,
-  setChatIsOpenAngular
+  setChatIsOpenAngular,
+  openDoc,
+  onlineUsersArray
 }) {
   const cobranding = window.brandVariation
     ? {
@@ -63,6 +65,8 @@ export function EditorProvider({
     loading,
     projectId: window.project_id,
     isProjectOwner: ownerId === window.user.id,
+    openDoc,
+    onlineUsersArray,
     ui: {
       chatIsOpen,
       toggleChatOpen
@@ -80,7 +84,9 @@ EditorProvider.propTypes = {
   children: PropTypes.any,
   loading: PropTypes.bool,
   chatIsOpenAngular: PropTypes.bool,
-  setChatIsOpenAngular: PropTypes.func.isRequired
+  setChatIsOpenAngular: PropTypes.func.isRequired,
+  openDoc: PropTypes.func.isRequired,
+  onlineUsersArray: PropTypes.array.isRequired
 }
 
 export function useEditorContext(propTypes) {

+ 8 - 2
services/web/frontend/js/shared/context/root-context.js

@@ -9,7 +9,9 @@ export function ContextRoot({
   children,
   editorLoading,
   chatIsOpenAngular,
-  setChatIsOpenAngular
+  setChatIsOpenAngular,
+  openDoc,
+  onlineUsersArray
 }) {
   return (
     <ApplicationProvider>
@@ -17,6 +19,8 @@ export function ContextRoot({
         loading={editorLoading}
         chatIsOpenAngular={chatIsOpenAngular}
         setChatIsOpenAngular={setChatIsOpenAngular}
+        openDoc={openDoc}
+        onlineUsersArray={onlineUsersArray}
       >
         <ChatProvider>{children}</ChatProvider>
       </EditorProvider>
@@ -28,7 +32,9 @@ ContextRoot.propTypes = {
   children: PropTypes.any,
   editorLoading: PropTypes.bool,
   chatIsOpenAngular: PropTypes.bool,
-  setChatIsOpenAngular: PropTypes.func.isRequired
+  setChatIsOpenAngular: PropTypes.func.isRequired,
+  openDoc: PropTypes.func.isRequired,
+  onlineUsersArray: PropTypes.array.isRequired
 }
 
 export const rootContext = createSharedContext(ContextRoot)

+ 31 - 3
services/web/frontend/stories/editor-navigation-toolbar.stories.js

@@ -1,10 +1,38 @@
 import React from 'react'
 import ToolbarHeader from '../js/features/editor-navigation-toolbar/components/toolbar-header'
 
-export const Default = () => {
-  return <ToolbarHeader />
+// required by ColorManager
+window.user = { id: 42 }
+
+export const UpToThreeConnectedUsers = args => {
+  return <ToolbarHeader {...args} />
+}
+UpToThreeConnectedUsers.args = {
+  onlineUsers: ['a', 'c', 'd'].map(c => ({
+    user_id: c,
+    name: `${c}_user name`
+  }))
+}
+
+export const ManyConnectedUsers = args => {
+  return <ToolbarHeader {...args} />
+}
+ManyConnectedUsers.args = {
+  onlineUsers: ['a', 'c', 'd', 'e', 'f'].map(c => ({
+    user_id: c,
+    name: `${c}_user name`
+  }))
 }
 
 export default {
-  title: 'EditorNavigationToolbar'
+  title: 'EditorNavigationToolbar',
+  component: ToolbarHeader,
+  argTypes: {
+    goToUser: { action: 'goToUser' }
+  },
+  args: {
+    onlineUsers: [{ user_id: 'abc', name: 'overleaf' }],
+    goToUser: () => {},
+    onShowLeftMenuClick: () => {}
+  }
 }

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

@@ -26,7 +26,12 @@ export function renderWithEditorContext(
   }
   return render(
     <ApplicationProvider>
-      <EditorProvider setChatIsOpen={() => {}} setChatIsOpenAngular={() => {}}>
+      <EditorProvider
+        setChatIsOpen={() => {}}
+        setChatIsOpenAngular={() => {}}
+        openDoc={() => {}}
+        onlineUsersArray={[]}
+      >
         {children}
       </EditorProvider>
     </ApplicationProvider>