From 146e4da73bf6ec1110130020c699cd075b9205b7 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 2 Jan 2020 21:17:59 -0800 Subject: [PATCH] feat: Document presence indicator (#1114) * Update websockets to allow joining document-based rooms * dynamic websocket joining * emit user.join/leave events when entering and exiting document rooms * presence storage * feat: frontend presence store * lint * UI updates * First pass editing state * refactoring * Timeout per user/doc lint * Document data loading refactor to keep Socket mounted * restore: Mark as viewed functionality Add display of 'you' to collaborators * fix: Socket/document remount when document slug changes due to title change * Revert unneccessary package update * Move editing ping interval to a shared constant * fix: Flash of sidebar when loading page directly on editing mode * separate document and revision loading * add comments for socket events * fix: Socket events getting bound multiple times on reconnect * fix: Clear client side presence state on disconnect * fix: Don't ignore server side error Improved documentation * More comments / why comments * rename Socket -> SocketPresence * fix: Handle redis is down remove unneccessary join * fix: PR feedback --- app/components/Actions.js | 1 + app/components/Avatar/Avatar.js | 32 +- app/components/Collaborators.js | 192 +++++------ .../DocumentHistory/DocumentHistory.js | 1 + app/components/RouteSidebarHidden.js | 28 -- app/components/SocketProvider.js | 306 +++++++++++------- app/models/View.js | 6 + app/routes.js | 5 +- app/scenes/Document/KeyedDocument.js | 12 +- app/scenes/Document/components/Container.js | 10 + app/scenes/Document/components/DataLoader.js | 172 ++++++++++ .../Document/{ => components}/Document.js | 243 ++++---------- .../Document/components/DocumentMove.js | 4 +- app/scenes/Document/components/Editor.js | 4 +- app/scenes/Document/components/Header.js | 5 +- app/scenes/Document/components/HideSidebar.js | 24 ++ app/scenes/Document/components/Loading.js | 22 ++ .../Document/components/LoadingPlaceholder.js | 4 +- .../Document/components/MarkAsViewed.js | 34 ++ .../Document/components/SocketPresence.js | 77 +++++ app/scenes/Document/index.js | 4 +- app/scenes/DocumentShare.js | 3 +- app/stores/DocumentPresenceStore.js | 69 ++++ app/stores/RootStore.js | 4 + app/stores/ViewsStore.js | 11 +- server/api/views.js | 13 +- server/index.js | 138 +++++++- .../20191228031525-edit-presence.js | 14 + server/models/View.js | 50 ++- server/services/websockets.js | 6 +- shared/constants.js | 3 + 31 files changed, 1013 insertions(+), 484 deletions(-) delete mode 100644 app/components/RouteSidebarHidden.js create mode 100644 app/scenes/Document/components/Container.js create mode 100644 app/scenes/Document/components/DataLoader.js rename app/scenes/Document/{ => components}/Document.js (59%) create mode 100644 app/scenes/Document/components/HideSidebar.js create mode 100644 app/scenes/Document/components/Loading.js create mode 100644 app/scenes/Document/components/MarkAsViewed.js create mode 100644 app/scenes/Document/components/SocketPresence.js create mode 100644 app/stores/DocumentPresenceStore.js create mode 100644 server/migrations/20191228031525-edit-presence.js create mode 100644 shared/constants.js diff --git a/app/components/Actions.js b/app/components/Actions.js index ccb5a868..370b99a2 100644 --- a/app/components/Actions.js +++ b/app/components/Actions.js @@ -18,6 +18,7 @@ export const Action = styled(Flex)` `; export const Separator = styled.div` + flex-shrink: 0; margin-left: 12px; width: 1px; height: 28px; diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js index 035cbada..a0c1f324 100644 --- a/app/components/Avatar/Avatar.js +++ b/app/components/Avatar/Avatar.js @@ -8,6 +8,7 @@ import placeholder from './placeholder.png'; type Props = { src: string, size: number, + icon?: React.Node, }; @observer @@ -23,18 +24,37 @@ class Avatar extends React.Component { }; render() { - const { src, ...rest } = this.props; + const { src, icon, ...rest } = this.props; return ( - + + + {icon && {icon}} + ); } } +const AvatarWrapper = styled.span` + position: relative; +`; + +const IconWrapper = styled.span` + display: flex; + position: absolute; + bottom: -2px; + right: -2px; + background: ${props => props.theme.primary}; + border: 2px solid ${props => props.theme.background}; + border-radius: 100%; + width: 20px; + height: 20px; +`; + const CircleImg = styled.img` width: ${props => props.size}px; height: ${props => props.size}px; diff --git a/app/components/Collaborators.js b/app/components/Collaborators.js index 147a0f7e..25eaf0c4 100644 --- a/app/components/Collaborators.js +++ b/app/components/Collaborators.js @@ -2,119 +2,136 @@ import * as React from 'react'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; -import { filter } from 'lodash'; +import { sortBy } from 'lodash'; +import styled, { withTheme } from 'styled-components'; import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; -import styled from 'styled-components'; + import Flex from 'shared/components/Flex'; import Avatar from 'components/Avatar'; import Tooltip from 'components/Tooltip'; import Document from 'models/Document'; +import User from 'models/User'; import UserProfile from 'scenes/UserProfile'; import ViewsStore from 'stores/ViewsStore'; +import DocumentPresenceStore from 'stores/DocumentPresenceStore'; +import { EditIcon } from 'outline-icons'; const MAX_DISPLAY = 6; type Props = { views: ViewsStore, + presence: DocumentPresenceStore, document: Document, + currentUserId: string, }; @observer -class Collaborators extends React.Component { - @observable openProfileId: ?string; +class AvatarWithPresence extends React.Component<{ + user: User, + isPresent: boolean, + isEditing: boolean, + isCurrentUser: boolean, + lastViewedAt: string, +}> { + @observable isOpen: boolean = false; + handleOpenProfile = () => { + this.isOpen = true; + }; + + handleCloseProfile = () => { + this.isOpen = false; + }; + + render() { + const { + user, + lastViewedAt, + isPresent, + isEditing, + isCurrentUser, + } = this.props; + + return ( + + + {user.name} {isCurrentUser && '(You)'} +
+ {isPresent + ? isEditing ? 'currently editing' : 'currently viewing' + : `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`} + + } + placement="bottom" + > + + : undefined} + /> + +
+ +
+ ); + } +} + +@observer +class Collaborators extends React.Component { componentDidMount() { this.props.views.fetchPage({ documentId: this.props.document.id }); } - handleOpenProfile = (userId: string) => { - this.openProfileId = userId; - }; - - handleCloseProfile = () => { - this.openProfileId = undefined; - }; - render() { - const { document, views } = this.props; + const { document, presence, views, currentUserId } = this.props; const documentViews = views.inDocument(document.id); - const { createdAt, updatedAt, updatedBy, collaborators } = document; - - // filter to only show views that haven't collaborated - const collaboratorIds = collaborators.map(user => user.id); - const viewersNotCollaborators = filter( - documentViews, - view => !collaboratorIds.includes(view.user.id) - ); + let documentPresence = presence.get(document.id); + documentPresence = documentPresence + ? Array.from(documentPresence.values()) + : []; + const presentIds = documentPresence.map(p => p.userId); + const editingIds = documentPresence + .filter(p => p.isEditing) + .map(p => p.userId); // only show the most recent viewers, the rest can overflow - const mostRecentViewers = viewersNotCollaborators.slice( - 0, - MAX_DISPLAY - collaborators.length + let mostRecentViewers = documentViews.slice(0, MAX_DISPLAY); + + // ensure currently present via websocket are always ordered first + mostRecentViewers = sortBy(mostRecentViewers, view => + presentIds.includes(view.user.id) ); // if there are too many to display then add a (+X) to the UI - const overflow = viewersNotCollaborators.length - mostRecentViewers.length; + const overflow = documentViews.length - mostRecentViewers.length; return ( {overflow > 0 && +{overflow}} - {mostRecentViewers.map(({ lastViewedAt, user }) => ( - - - {user.name} -
- viewed {distanceInWordsToNow(new Date(lastViewedAt))} ago - - } - placement="bottom" - > - - this.handleOpenProfile(user.id)} - size={32} - /> - -
- { + const isPresent = presentIds.includes(user.id); + const isEditing = editingIds.includes(user.id); + + return ( + -
- ))} - {collaborators.map(user => ( - - - {user.name} -
- {createdAt === updatedAt ? 'published' : 'updated'}{' '} - {updatedBy.id === user.id && - `${distanceInWordsToNow(new Date(updatedAt))} ago`} - - } - placement="bottom" - > - - this.handleOpenProfile(user.id)} - size={32} - /> - -
- -
- ))} + ); + })}
); } @@ -124,21 +141,12 @@ const Centered = styled.div` text-align: center; `; -const Viewer = styled.div` - width: 32px; - height: 32px; - opacity: 0.75; - margin-right: -8px; - - &:first-child { - margin-right: 0; - } -`; - -const Collaborator = styled.div` +const AvatarWrapper = styled.div` width: 32px; height: 32px; margin-right: -8px; + opacity: ${props => (props.isPresent ? 1 : 0.5)}; + transition: opacity 250ms ease-in-out; &:first-child { margin-right: 0; @@ -164,4 +172,4 @@ const Avatars = styled(Flex)` cursor: pointer; `; -export default inject('views')(Collaborators); +export default inject('views', 'presence')(withTheme(Collaborators)); diff --git a/app/components/DocumentHistory/DocumentHistory.js b/app/components/DocumentHistory/DocumentHistory.js index bee69c46..693bacb7 100644 --- a/app/components/DocumentHistory/DocumentHistory.js +++ b/app/components/DocumentHistory/DocumentHistory.js @@ -137,6 +137,7 @@ const Wrapper = styled(Flex)` border-left: 1px solid ${props => props.theme.divider}; overflow: scroll; overscroll-behavior: none; + z-index: 1; `; export default inject('documents', 'revisions')(DocumentHistory); diff --git a/app/components/RouteSidebarHidden.js b/app/components/RouteSidebarHidden.js deleted file mode 100644 index d5d0f144..00000000 --- a/app/components/RouteSidebarHidden.js +++ /dev/null @@ -1,28 +0,0 @@ -// @flow -import * as React from 'react'; -import { inject } from 'mobx-react'; -import { Route } from 'react-router-dom'; -import UiStore from 'stores/UiStore'; - -type Props = { - ui: UiStore, - component: React.ComponentType, -}; - -class RouteSidebarHidden extends React.Component { - componentDidMount() { - this.props.ui.enableEditMode(); - } - - componentWillUnmount() { - this.props.ui.disableEditMode(); - } - - render() { - const { component, ui, ...rest } = this.props; - const Component = component; - return } />; - } -} - -export default inject('ui')(RouteSidebarHidden); diff --git a/app/components/SocketProvider.js b/app/components/SocketProvider.js index 605dcfc7..d0c3ed6b 100644 --- a/app/components/SocketProvider.js +++ b/app/components/SocketProvider.js @@ -1,29 +1,35 @@ // @flow import * as React from 'react'; -import { inject } from 'mobx-react'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; import { find } from 'lodash'; import io from 'socket.io-client'; import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; import MembershipsStore from 'stores/MembershipsStore'; +import DocumentPresenceStore from 'stores/DocumentPresenceStore'; import PoliciesStore from 'stores/PoliciesStore'; +import ViewsStore from 'stores/ViewsStore'; import AuthStore from 'stores/AuthStore'; import UiStore from 'stores/UiStore'; -const SocketContext = React.createContext(); +export const SocketContext: any = React.createContext(); type Props = { children: React.Node, documents: DocumentsStore, collections: CollectionsStore, memberships: MembershipsStore, + presence: DocumentPresenceStore, policies: PoliciesStore, + views: ViewsStore, auth: AuthStore, ui: UiStore, }; +@observer class SocketProvider extends React.Component { - socket; + @observable socket; componentDidMount() { if (!process.env.WEBSOCKETS_ENABLED) return; @@ -31,6 +37,7 @@ class SocketProvider extends React.Component { this.socket = io(window.location.origin, { path: '/realtime', }); + this.socket.authenticated = false; const { auth, @@ -39,164 +46,213 @@ class SocketProvider extends React.Component { collections, memberships, policies, + presence, + views, } = this.props; if (!auth.token) return; this.socket.on('connect', () => { + // immediately send current users token to the websocket backend where it + // is verified, if all goes well an 'authenticated' message will be + // received in response this.socket.emit('authentication', { token: auth.token, }); + }); - this.socket.on('unauthorized', err => { - ui.showToast(err.message); - throw err; - }); + this.socket.on('disconnect', () => { + // when the socket is disconnected we need to clear all presence state as + // it's no longer reliable. + presence.clear(); + }); - this.socket.on('entities', async event => { - if (event.documentIds) { - for (const documentDescriptor of event.documentIds) { - const documentId = documentDescriptor.id; - let document = documents.get(documentId) || {}; + this.socket.on('authenticated', () => { + this.socket.authenticated = true; + }); - if (event.event === 'documents.delete') { - const document = documents.get(documentId); - if (document) { - document.deletedAt = documentDescriptor.updatedAt; - } - continue; + this.socket.on('unauthorized', err => { + this.socket.authenticated = false; + ui.showToast(err.message); + throw err; + }); + + this.socket.on('entities', async event => { + if (event.documentIds) { + for (const documentDescriptor of event.documentIds) { + const documentId = documentDescriptor.id; + let document = documents.get(documentId) || {}; + + if (event.event === 'documents.delete') { + const document = documents.get(documentId); + if (document) { + document.deletedAt = documentDescriptor.updatedAt; + } + continue; + } + + // if we already have the latest version (it was us that performed + // the change) then we don't need to update anything either. + const { title, updatedAt } = document; + if (updatedAt === documentDescriptor.updatedAt) { + continue; + } + + // otherwise, grab the latest version of the document + try { + document = await documents.fetch(documentId, { + force: true, + }); + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 403) { + documents.remove(documentId); + return; + } + } + + // if the title changed then we need to update the collection also + if (title !== document.title) { + if (!event.collectionIds) { + event.collectionIds = []; } - // if we already have the latest version (it was us that performed the change) - // the we don't need to update anything either. - const { title, updatedAt } = document; - if (updatedAt === documentDescriptor.updatedAt) { - continue; - } + const existing = find(event.collectionIds, { + id: document.collectionId, + }); - // otherwise, grab the latest version of the document - try { - document = await documents.fetch(documentId, { - force: true, - }); - } catch (err) { - if (err.statusCode === 404 || err.statusCode === 403) { - documents.remove(documentId); - return; - } - } - - // if the title changed then we need to update the collection also - if (title !== document.title) { - if (!event.collectionIds) { - event.collectionIds = []; - } - - const existing = find(event.collectionIds, { + if (!existing) { + event.collectionIds.push({ id: document.collectionId, }); - - if (!existing) { - event.collectionIds.push({ - id: document.collectionId, - }); - } } + } - // TODO: Move this to the document scene once data loading - // has been refactored to be friendlier there. - if ( - auth.user && - documentId === ui.activeDocumentId && - document.updatedBy.id !== auth.user.id - ) { - ui.showToast(`Document updated by ${document.updatedBy.name}`, { - timeout: 30 * 1000, - action: { - text: 'Refresh', - onClick: () => window.location.reload(), - }, - }); + // TODO: Move this to the document scene once data loading + // has been refactored to be friendlier there. + if ( + auth.user && + documentId === ui.activeDocumentId && + document.updatedBy.id !== auth.user.id + ) { + ui.showToast(`Document updated by ${document.updatedBy.name}`, { + timeout: 30 * 1000, + action: { + text: 'Refresh', + onClick: () => window.location.reload(), + }, + }); + } + } + } + + if (event.collectionIds) { + for (const collectionDescriptor of event.collectionIds) { + const collectionId = collectionDescriptor.id; + const collection = collections.get(collectionId) || {}; + + if (event.event === 'collections.delete') { + documents.remove(collectionId); + continue; + } + + // if we already have the latest version (it was us that performed + // the change) then we don't need to update anything either. + const { updatedAt } = collection; + if (updatedAt === collectionDescriptor.updatedAt) { + continue; + } + + try { + await collections.fetch(collectionId, { force: true }); + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 403) { + collections.remove(collectionId); + documents.removeCollectionDocuments(collectionId); + memberships.removeCollectionMemberships(collectionId); + return; } } } + } + }); - if (event.collectionIds) { - for (const collectionDescriptor of event.collectionIds) { - const collectionId = collectionDescriptor.id; - const collection = collections.get(collectionId) || {}; + this.socket.on('documents.star', event => { + documents.starredIds.set(event.documentId, true); + }); - if (event.event === 'collections.delete') { - documents.remove(collectionId); - continue; - } + this.socket.on('documents.unstar', event => { + documents.starredIds.set(event.documentId, false); + }); - // if we already have the latest version (it was us that performed the change) - // the we don't need to update anything either. - const { updatedAt } = collection; - if (updatedAt === collectionDescriptor.updatedAt) { - continue; - } + // received when a user is given access to a collection + // if the user is us then we go ahead and load the collection from API. + this.socket.on('collections.add_user', event => { + if (auth.user && event.userId === auth.user.id) { + collections.fetch(event.collectionId, { force: true }); + } - try { - await collections.fetch(collectionId, { force: true }); - } catch (err) { - if (err.statusCode === 404 || err.statusCode === 403) { - collections.remove(collectionId); - documents.removeCollectionDocuments(collectionId); - memberships.removeCollectionMemberships(collectionId); - return; - } - } - } - } + // Document policies might need updating as the permission changes + documents.inCollection(event.collectionId).forEach(document => { + policies.remove(document.id); }); + }); - this.socket.on('documents.star', event => { - documents.starredIds.set(event.documentId, true); - }); + // received when a user is removed from having access to a collection + // to keep state in sync we must update our UI if the user is us, + // or otherwise just remove any membership state we have for that user. + this.socket.on('collections.remove_user', event => { + if (auth.user && event.userId === auth.user.id) { + collections.remove(event.collectionId); + memberships.removeCollectionMemberships(event.collectionId); + documents.removeCollectionDocuments(event.collectionId); + } else { + memberships.remove(`${event.userId}-${event.collectionId}`); + } + }); - this.socket.on('documents.unstar', event => { - documents.starredIds.set(event.documentId, false); - }); + // received a message from the API server that we should request + // to join a specific room. Forward that to the ws server. + this.socket.on('join', event => { + this.socket.emit('join', event); + }); - this.socket.on('collections.add_user', event => { - if (auth.user && event.userId === auth.user.id) { - collections.fetch(event.collectionId, { force: true }); - } + // received a message from the API server that we should request + // to leave a specific room. Forward that to the ws server. + this.socket.on('leave', event => { + this.socket.emit('leave', event); + }); - // Document policies might need updating as the permission changes - documents.inCollection(event.collectionId).forEach(document => { - policies.remove(document.id); - }); - }); + // received whenever we join a document room, the payload includes + // userIds that are present/viewing and those that are editing. + this.socket.on('document.presence', event => { + presence.init(event.documentId, event.userIds, event.editingIds); + }); - this.socket.on('collections.remove_user', event => { - if (auth.user && event.userId === auth.user.id) { - collections.remove(event.collectionId); - memberships.removeCollectionMemberships(event.collectionId); - documents.removeCollectionDocuments(event.collectionId); - } else { - memberships.remove(`${event.userId}-${event.collectionId}`); - } - }); + // received whenever a new user joins a document room, aka they + // navigate to / start viewing a document + this.socket.on('user.join', event => { + presence.touch(event.documentId, event.userId, event.isEditing); + views.touch(event.documentId, event.userId); + }); - // received a message from the API server that we should request - // to join a specific room. Forward that to the ws server. - this.socket.on('join', event => { - this.socket.emit('join', event); - }); + // received whenever a new user leaves a document room, aka they + // navigate away / stop viewing a document + this.socket.on('user.leave', event => { + presence.leave(event.documentId, event.userId); + views.touch(event.documentId, event.userId); + }); - // received a message from the API server that we should request - // to leave a specific room. Forward that to the ws server. - this.socket.on('leave', event => { - this.socket.emit('leave', event); - }); + // received when another client in a document room wants to change + // or update it's presence. Currently the only property is whether + // the client is in editing state or not. + this.socket.on('user.presence', event => { + presence.touch(event.documentId, event.userId, event.isEditing); }); } componentWillUnmount() { if (this.socket) { this.socket.disconnect(); + this.socket.authenticated = false; } } @@ -215,5 +271,7 @@ export default inject( 'documents', 'collections', 'memberships', - 'policies' + 'presence', + 'policies', + 'views' )(SocketProvider); diff --git a/app/models/View.js b/app/models/View.js index ebb24bf1..40c244aa 100644 --- a/app/models/View.js +++ b/app/models/View.js @@ -1,4 +1,5 @@ // @flow +import { action } from 'mobx'; import BaseModel from './BaseModel'; import User from './User'; @@ -9,6 +10,11 @@ class View extends BaseModel { lastViewedAt: string; count: number; user: User; + + @action + touch() { + this.lastViewedAt = new Date().toString(); + } } export default View; diff --git a/app/routes.js b/app/routes.js index 994c5085..f91aaa38 100644 --- a/app/routes.js +++ b/app/routes.js @@ -27,7 +27,6 @@ import Error404 from 'scenes/Error404'; import Layout from 'components/Layout'; import SocketProvider from 'components/SocketProvider'; import Authenticated from 'components/Authenticated'; -import RouteSidebarHidden from 'components/RouteSidebarHidden'; import { matchDocumentSlug as slug } from 'utils/routeHelpers'; const NotFound = () => ; @@ -75,7 +74,7 @@ export default function Routes() { component={Zapier} /> - - { componentWillUnmount() { @@ -9,7 +9,15 @@ class KeyedDocument extends React.Component<*> { } render() { - return ; + const { match } = this.props; + + // the urlId portion of the url does not include the slugified title + // we only want to force a re-mount of the document component when the + // document changes, not when the title does so only this portion is used + // for the key. + const urlId = match.params.documentSlug.split('-')[1]; + + return ; } } diff --git a/app/scenes/Document/components/Container.js b/app/scenes/Document/components/Container.js new file mode 100644 index 00000000..b60ee3fc --- /dev/null +++ b/app/scenes/Document/components/Container.js @@ -0,0 +1,10 @@ +// @flow +import styled from 'styled-components'; +import Flex from 'shared/components/Flex'; + +const Container = styled(Flex)` + position: relative; + margin-top: ${props => (props.isShare ? '50px' : '0')}; +`; + +export default Container; diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js new file mode 100644 index 00000000..fbea2376 --- /dev/null +++ b/app/scenes/Document/components/DataLoader.js @@ -0,0 +1,172 @@ +// @flow +import * as React from 'react'; +import { withRouter } from 'react-router-dom'; +import type { Location, RouterHistory } from 'react-router-dom'; +import { observable } from 'mobx'; +import { observer, inject } from 'mobx-react'; +import { matchDocumentEdit, updateDocumentUrl } from 'utils/routeHelpers'; +import DocumentComponent from './Document'; +import Revision from 'models/Revision'; +import Document from 'models/Document'; +import SocketPresence from './SocketPresence'; +import Loading from './Loading'; +import HideSidebar from './HideSidebar'; +import Error404 from 'scenes/Error404'; +import ErrorOffline from 'scenes/ErrorOffline'; +import DocumentsStore from 'stores/DocumentsStore'; +import PoliciesStore from 'stores/PoliciesStore'; +import RevisionsStore from 'stores/RevisionsStore'; +import UiStore from 'stores/UiStore'; + +type Props = {| + match: Object, + location: Location, + documents: DocumentsStore, + policies: PoliciesStore, + revisions: RevisionsStore, + ui: UiStore, + history: RouterHistory, +|}; + +@observer +class DataLoader extends React.Component { + @observable document: ?Document; + @observable revision: ?Revision; + @observable error: ?Error; + + componentDidMount() { + const { documents, match } = this.props; + this.document = documents.getByUrl(match.params.documentSlug); + this.loadDocument(); + } + + componentDidUpdate(prevProps: Props) { + // If we have the document in the store, but not it's policy then we need to + // reload from the server otherwise the UI will not know which authorizations + // the user has + if (this.document) { + const policy = this.props.policies.get(this.document.id); + + if (!policy) { + this.loadDocument(); + } + } + + // Also need to load the revision if it changes + if ( + prevProps.match.params.revisionId !== this.props.match.params.revisionId + ) { + this.loadRevision(); + } + } + + goToDocumentCanonical = () => { + if (this.document) { + this.props.history.push(this.document.url); + } + }; + + get isEditing() { + return this.props.match.path === matchDocumentEdit; + } + + onSearchLink = async (term: string) => { + const results = await this.props.documents.search(term); + + return results.map((result, index) => ({ + title: result.document.title, + url: result.document.url, + })); + }; + + loadRevision = async () => { + const { documentSlug, revisionId } = this.props.match.params; + + this.revision = await this.props.revisions.fetch(documentSlug, { + revisionId, + }); + }; + + loadDocument = async () => { + const { shareId, documentSlug, revisionId } = this.props.match.params; + + try { + this.document = await this.props.documents.fetch(documentSlug, { + shareId, + }); + + if (revisionId) { + await this.loadRevision(); + } else { + this.revision = undefined; + } + } catch (err) { + this.error = err; + return; + } + + const document = this.document; + + if (document) { + this.props.ui.setActiveDocument(document); + + if (document.isArchived && this.isEditing) { + return this.goToDocumentCanonical(); + } + + const isMove = this.props.location.pathname.match(/move$/); + const canRedirect = !revisionId && !isMove && !shareId; + if (canRedirect) { + const canonicalUrl = updateDocumentUrl( + this.props.match.url, + document.url + ); + if (this.props.location.pathname !== canonicalUrl) { + this.props.history.replace(canonicalUrl); + } + } + } + }; + + render() { + const { location, policies, ui } = this.props; + + if (this.error) { + return navigator.onLine ? : ; + } + + const document = this.document; + const revision = this.revision; + + if (!document) { + return ( + + + {this.isEditing && } + + ); + } + + const abilities = policies.abilities(document.id); + const key = this.isEditing ? 'editing' : 'read-only'; + + return ( + + {this.isEditing && } + + + ); + } +} + +export default withRouter( + inject('ui', 'auth', 'documents', 'revisions', 'policies')(DataLoader) +); diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/components/Document.js similarity index 59% rename from app/scenes/Document/Document.js rename to app/scenes/Document/components/Document.js index a6674829..4dd19847 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/components/Document.js @@ -11,43 +11,36 @@ import keydown from 'react-keydown'; import Flex from 'shared/components/Flex'; import { collectionUrl, - updateDocumentUrl, documentMoveUrl, documentHistoryUrl, documentEditUrl, - matchDocumentEdit, } from 'utils/routeHelpers'; import { emojiToUrl } from 'utils/emoji'; -import Header from './components/Header'; -import DocumentMove from './components/DocumentMove'; -import Branding from './components/Branding'; -import KeyboardShortcuts from './components/KeyboardShortcuts'; -import References from './components/References'; +import Header from './Header'; +import DocumentMove from './DocumentMove'; +import Branding from './Branding'; +import KeyboardShortcuts from './KeyboardShortcuts'; +import References from './References'; +import Loading from './Loading'; +import Container from './Container'; +import MarkAsViewed from './MarkAsViewed'; import ErrorBoundary from 'components/ErrorBoundary'; -import LoadingPlaceholder from 'components/LoadingPlaceholder'; import LoadingIndicator from 'components/LoadingIndicator'; -import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import Notice from 'shared/components/Notice'; import Time from 'shared/components/Time'; -import Error404 from 'scenes/Error404'; -import ErrorOffline from 'scenes/ErrorOffline'; import UiStore from 'stores/UiStore'; import AuthStore from 'stores/AuthStore'; -import DocumentsStore from 'stores/DocumentsStore'; -import PoliciesStore from 'stores/PoliciesStore'; -import RevisionsStore from 'stores/RevisionsStore'; import Document from 'models/Document'; import Revision from 'models/Revision'; -import schema from './schema'; +import schema from '../schema'; let EditorImport; const AUTOSAVE_DELAY = 3000; const IS_DIRTY_DELAY = 500; -const MARK_AS_VIEWED_AFTER = 3000; const DISCARD_CHANGES = ` You have unsaved changes. Are you sure you want to discard them? @@ -61,63 +54,38 @@ type Props = { match: Object, history: RouterHistory, location: Location, - policies: PoliciesStore, - documents: DocumentsStore, - revisions: RevisionsStore, + abilities: Object, + document: Document, + revision: Revision, + readOnly: boolean, + onSearchLink: (term: string) => mixed, auth: AuthStore, ui: UiStore, }; @observer class DocumentScene extends React.Component { - viewTimeout: TimeoutID; getEditorText: () => string; @observable editorComponent = EditorImport; - @observable document: ?Document; - @observable revision: ?Revision; @observable isUploading: boolean = false; @observable isSaving: boolean = false; @observable isPublishing: boolean = false; @observable isDirty: boolean = false; @observable isEmpty: boolean = true; - @observable error: ?Error; @observable moveModalOpen: boolean = false; constructor(props) { super(); - this.document = props.documents.getByUrl(props.match.params.documentSlug); - this.loadDocument(props); this.loadEditor(); } - componentDidUpdate() { - if (this.document) { - const policy = this.props.policies.get(this.document.id); - - if (!policy) { - this.loadDocument(this.props); - } - } - } - - componentWillUnmount() { - clearTimeout(this.viewTimeout); - } - - goToDocumentCanonical = () => { - if (this.document) this.props.history.push(this.document.url); - }; - @keydown('m') goToMove(ev) { ev.preventDefault(); - const document = this.document; - if (!document) return; + const { document, abilities } = this.props; - const can = this.props.policies.abilities(document.id); - - if (can.update) { + if (abilities.update) { this.props.history.push(documentMoveUrl(document)); } } @@ -125,120 +93,56 @@ class DocumentScene extends React.Component { @keydown('e') goToEdit(ev) { ev.preventDefault(); - const document = this.document; - if (!document) return; + const { document, abilities } = this.props; - const can = this.props.policies.abilities(document.id); - - if (can.update) { + if (abilities.update) { this.props.history.push(documentEditUrl(document)); } } @keydown('esc') goBack(ev) { - if (this.isEditing) { - ev.preventDefault(); - this.props.history.goBack(); - } + if (this.props.readOnly) return; + + ev.preventDefault(); + this.props.history.goBack(); } @keydown('h') goToHistory(ev) { ev.preventDefault(); - if (!this.document) return; + const { document, revision } = this.props; - if (this.revision) { - this.props.history.push(this.document.url); + if (revision) { + this.props.history.push(document.url); } else { - this.props.history.push(documentHistoryUrl(this.document)); + this.props.history.push(documentHistoryUrl(document)); } } @keydown('meta+shift+p') onPublish(ev) { ev.preventDefault(); - if (!this.document) return; - if (this.document.publishedAt) return; + const { document } = this.props; + if (document.publishedAt) return; this.onSave({ publish: true, done: true }); } - loadDocument = async props => { - const { shareId, revisionId } = props.match.params; - - try { - this.document = await props.documents.fetch( - props.match.params.documentSlug, - { shareId } - ); - - if (revisionId) { - this.revision = await props.revisions.fetch( - props.match.params.documentSlug, - { revisionId } - ); - } else { - this.revision = undefined; - } - } catch (err) { - this.error = err; - return; - } - - this.isDirty = false; - this.isEmpty = false; - - const document = this.document; - - if (document) { - this.props.ui.setActiveDocument(document); - - if (document.isArchived && this.isEditing) { - return this.goToDocumentCanonical(); - } - - if (this.props.auth.user && !shareId) { - if (!this.isEditing && document.publishedAt) { - this.viewTimeout = setTimeout(document.view, MARK_AS_VIEWED_AFTER); - } - - const isMove = props.location.pathname.match(/move$/); - const canRedirect = !this.revision && !isMove; - if (canRedirect) { - const canonicalUrl = updateDocumentUrl(props.match.url, document.url); - if (props.location.pathname !== canonicalUrl) { - props.history.replace(canonicalUrl); - } - } - } - } - }; - loadEditor = async () => { if (this.editorComponent) return; - const Imported = await import('./components/Editor'); + const Imported = await import('./Editor'); EditorImport = Imported.default; this.editorComponent = EditorImport; }; - get isEditing() { - const document = this.document; - - return !!( - this.props.match.path === matchDocumentEdit || - (document && !document.id) - ); - } - handleCloseMoveModal = () => (this.moveModalOpen = false); handleOpenMoveModal = () => (this.moveModalOpen = true); onSave = async ( options: { done?: boolean, publish?: boolean, autosave?: boolean } = {} ) => { - let document = this.document; - if (!document) return; + const { document } = this.props; // prevent saves when we are already saving if (document.isSaving) return; @@ -257,17 +161,17 @@ class DocumentScene extends React.Component { let isNew = !document.id; this.isSaving = true; this.isPublishing = !!options.publish; - document = await document.save(options); + const savedDocument = await document.save(options); this.isDirty = false; this.isSaving = false; this.isPublishing = false; if (options.done) { - this.props.history.push(document.url); - this.props.ui.setActiveDocument(document); + this.props.history.push(savedDocument.url); + this.props.ui.setActiveDocument(savedDocument); } else if (isNew) { - this.props.history.push(documentEditUrl(document)); - this.props.ui.setActiveDocument(document); + this.props.history.push(documentEditUrl(savedDocument)); + this.props.ui.setActiveDocument(savedDocument); } }; @@ -276,7 +180,7 @@ class DocumentScene extends React.Component { }, AUTOSAVE_DELAY); updateIsDirty = debounce(() => { - const document = this.document; + const { document } = this.props; const editorText = this.getEditorText().trim(); // a single hash is a doc with just an empty title @@ -298,55 +202,28 @@ class DocumentScene extends React.Component { this.autosave(); }; - onDiscard = () => { + goBack = () => { let url; - if (this.document && this.document.url) { - url = this.document.url; + if (this.props.document.url) { + url = this.props.document.url; } else { url = collectionUrl(this.props.match.params.id); } this.props.history.push(url); }; - onSearchLink = async (term: string) => { - const results = await this.props.documents.search(term); - - return results.map((result, index) => ({ - title: result.document.title, - url: result.document.url, - })); - }; - render() { - const { location, auth, match } = this.props; + const { document, revision, readOnly, location, auth, match } = this.props; const team = auth.team; const Editor = this.editorComponent; - const document = this.document; - const revision = this.revision; const isShare = match.params.shareId; - if (this.error) { - return navigator.onLine ? : ; - } - - if (!document || !Editor) { - return ( - - - - - - - ); + if (!Editor) { + return ; } const embedsDisabled = team && !team.documentEmbeds; - // this line is only here to make MobX understand that policies are a dependency of this component - this.props.policies.abilities(document.id); - return ( { ( - + )} /> { {(this.isUploading || this.isSaving) && } - {this.isEditing && ( + {!readOnly && ( { document={document} isRevision={!!revision} isDraft={document.isDraft} - isEditing={this.isEditing} + isEditing={!readOnly} isSaving={this.isSaving} isPublishing={this.isPublishing} publishingIsDisabled={ document.isSaving || this.isPublishing || this.isEmpty } savingIsDisabled={document.isSaving || this.isEmpty} - onDiscard={this.onDiscard} + goBack={this.goBack} onSave={this.onSave} /> )} @@ -429,21 +303,25 @@ class DocumentScene extends React.Component { disableEmbeds={embedsDisabled} onImageUploadStart={this.onImageUploadStart} onImageUploadStop={this.onImageUploadStop} - onSearchLink={this.onSearchLink} + onSearchLink={this.props.onSearchLink} onChange={this.onChange} onSave={this.onSave} onPublish={this.onPublish} - onCancel={this.onDiscard} - readOnly={!this.isEditing || document.isArchived} + onCancel={this.goBack} + readOnly={readOnly || document.isArchived} toc={!revision} ui={this.props.ui} schema={schema} /> - {!this.isEditing && - !isShare && ( - - - + {readOnly && + !isShare && + !revision && ( + + + + + + )} @@ -473,11 +351,6 @@ const MaxWidth = styled(Flex)` `}; `; -const Container = styled(Flex)` - position: relative; - margin-top: ${props => (props.isShare ? '50px' : '0')}; -`; - export default withRouter( inject('ui', 'auth', 'documents', 'policies', 'revisions')(DocumentScene) ); diff --git a/app/scenes/Document/components/DocumentMove.js b/app/scenes/Document/components/DocumentMove.js index 7c10c2c1..9d6cc299 100644 --- a/app/scenes/Document/components/DocumentMove.js +++ b/app/scenes/Document/components/DocumentMove.js @@ -21,13 +21,13 @@ import CollectionsStore, { type DocumentPath } from 'stores/CollectionsStore'; const MAX_RESULTS = 8; -type Props = { +type Props = {| document: Document, documents: DocumentsStore, collections: CollectionsStore, ui: UiStore, onRequestClose: () => void, -}; +|}; @observer class DocumentMove extends React.Component { diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 527531f5..d8013c88 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -4,10 +4,10 @@ import Editor from 'components/Editor'; import ClickablePadding from 'components/ClickablePadding'; import plugins from './plugins'; -type Props = { +type Props = {| defaultValue?: string, readOnly?: boolean, -}; +|}; class DocumentEditor extends React.Component { editor: ?Editor; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index ac98a104..032c8a59 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -143,13 +143,16 @@ class Header extends React.Component { )} - {!isDraft && !isEditing && } {isSaving && !isPublishing && ( Saving… )} + {!isDraft && !isEditing && canShareDocuments && ( diff --git a/app/scenes/Document/components/HideSidebar.js b/app/scenes/Document/components/HideSidebar.js new file mode 100644 index 00000000..98a6f118 --- /dev/null +++ b/app/scenes/Document/components/HideSidebar.js @@ -0,0 +1,24 @@ +// @flow +import * as React from 'react'; +import UiStore from 'stores/UiStore'; + +type Props = { + ui: UiStore, + children?: React.Node, +}; + +class HideSidebar extends React.Component { + componentDidMount() { + this.props.ui.enableEditMode(); + } + + componentWillUnmount() { + this.props.ui.disableEditMode(); + } + + render() { + return this.props.children || null; + } +} + +export default HideSidebar; diff --git a/app/scenes/Document/components/Loading.js b/app/scenes/Document/components/Loading.js new file mode 100644 index 00000000..10b4e243 --- /dev/null +++ b/app/scenes/Document/components/Loading.js @@ -0,0 +1,22 @@ +// @flow +import * as React from 'react'; +import type { Location } from 'react-router-dom'; +import Container from './Container'; +import LoadingPlaceholder from 'components/LoadingPlaceholder'; +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; + +type Props = {| + location: Location, +|}; + +export default function Loading({ location }: Props) { + return ( + + + + + + + ); +} diff --git a/app/scenes/Document/components/LoadingPlaceholder.js b/app/scenes/Document/components/LoadingPlaceholder.js index 05073b9b..396facc5 100644 --- a/app/scenes/Document/components/LoadingPlaceholder.js +++ b/app/scenes/Document/components/LoadingPlaceholder.js @@ -12,10 +12,10 @@ const randomValues = Array.from( () => `${randomInteger(85, 100)}%` ); -const LoadingPlaceholder = (props: Object) => { +const LoadingPlaceholder = () => { return ( - + diff --git a/app/scenes/Document/components/MarkAsViewed.js b/app/scenes/Document/components/MarkAsViewed.js new file mode 100644 index 00000000..05c045f6 --- /dev/null +++ b/app/scenes/Document/components/MarkAsViewed.js @@ -0,0 +1,34 @@ +// @flow +import * as React from 'react'; +import Document from 'models/Document'; + +const MARK_AS_VIEWED_AFTER = 3 * 1000; + +type Props = {| + document: Document, + children?: React.Node, +|}; + +class MarkAsViewed extends React.Component { + viewTimeout: TimeoutID; + + componentDidMount() { + const { document } = this.props; + + this.viewTimeout = setTimeout(() => { + if (document.publishedAt) { + document.view(); + } + }, MARK_AS_VIEWED_AFTER); + } + + componentWillUnmount() { + clearTimeout(this.viewTimeout); + } + + render() { + return this.props.children || null; + } +} + +export default MarkAsViewed; diff --git a/app/scenes/Document/components/SocketPresence.js b/app/scenes/Document/components/SocketPresence.js new file mode 100644 index 00000000..2994dd3c --- /dev/null +++ b/app/scenes/Document/components/SocketPresence.js @@ -0,0 +1,77 @@ +// @flow +import * as React from 'react'; +import { SocketContext } from 'components/SocketProvider'; +import { USER_PRESENCE_INTERVAL } from 'shared/constants'; + +type Props = { + children?: React.Node, + documentId: string, + isEditing: boolean, +}; + +export default class SocketPresence extends React.Component { + static contextType = SocketContext; + previousContext: any; + editingInterval: IntervalID; + + componentDidMount() { + this.editingInterval = setInterval(() => { + if (this.props.isEditing) { + this.emitPresence(); + } + }, USER_PRESENCE_INTERVAL); + this.setupOnce(); + } + + componentDidUpdate(prevProps: Props) { + this.setupOnce(); + + if (prevProps.isEditing !== this.props.isEditing) { + this.emitPresence(); + } + } + + componentWillUnmount() { + if (this.context) { + this.context.emit('leave', { documentId: this.props.documentId }); + this.context.off('authenticated', this.emitJoin); + } + + clearInterval(this.editingInterval); + } + + setupOnce = () => { + if (this.context && !this.previousContext) { + this.previousContext = this.context; + + if (this.context.authenticated) { + this.emitJoin(); + } + this.context.on('authenticated', () => { + this.emitJoin(); + }); + } + }; + + emitJoin = () => { + if (!this.context) return; + + this.context.emit('join', { + documentId: this.props.documentId, + isEditing: this.props.isEditing, + }); + }; + + emitPresence = () => { + if (!this.context) return; + + this.context.emit('presence', { + documentId: this.props.documentId, + isEditing: this.props.isEditing, + }); + }; + + render() { + return this.props.children || null; + } +} diff --git a/app/scenes/Document/index.js b/app/scenes/Document/index.js index 8ec6fd12..8b8fbddd 100644 --- a/app/scenes/Document/index.js +++ b/app/scenes/Document/index.js @@ -1,3 +1,3 @@ // @flow -import Document from './Document'; -export default Document; +import DataLoader from './components/DataLoader'; +export default DataLoader; diff --git a/app/scenes/DocumentShare.js b/app/scenes/DocumentShare.js index a9887456..81c2889e 100644 --- a/app/scenes/DocumentShare.js +++ b/app/scenes/DocumentShare.js @@ -10,7 +10,7 @@ import HelpText from 'components/HelpText'; import Document from 'models/Document'; type Props = { - document?: Document, + document: Document, onSubmit: () => void, }; @@ -34,7 +34,6 @@ class DocumentShare extends React.Component { render() { const { document, onSubmit } = this.props; - if (!document) return null; return (
diff --git a/app/stores/DocumentPresenceStore.js b/app/stores/DocumentPresenceStore.js new file mode 100644 index 00000000..e368a8c5 --- /dev/null +++ b/app/stores/DocumentPresenceStore.js @@ -0,0 +1,69 @@ +// @flow +import { observable, action } from 'mobx'; +import { USER_PRESENCE_INTERVAL } from 'shared/constants'; + +type DocumentPresence = Map; + +export default class PresenceStore { + @observable data: Map = new Map(); + timeouts: Map = new Map(); + + // called to setup when we get the initial state from document.presence + // websocket message. overrides any existing state + @action + init(documentId: string, userIds: string[], editingIds: string[]) { + this.data.set(documentId, new Map()); + userIds.forEach(userId => + this.touch(documentId, userId, editingIds.includes(userId)) + ); + } + + // called when a user leave the room – user.leave websocket message. + @action + leave(documentId: string, userId: string) { + const existing = this.data.get(documentId); + if (existing) { + existing.delete(userId); + } + } + + @action + update(documentId: string, userId: string, isEditing: boolean) { + const existing = this.data.get(documentId) || new Map(); + existing.set(userId, { isEditing, userId }); + this.data.set(documentId, existing); + } + + // called when a user presence message is received – user.presence websocket + // message. + // While in edit mode a message is sent every USER_PRESENCE_INTERVAL, if + // the other clients don't receive within USER_PRESENCE_INTERVAL*2 then a + // timeout is triggered causing the users presence to default back to not + // editing state as a safety measure. + touch(documentId: string, userId: string, isEditing: boolean) { + const id = `${documentId}-${userId}`; + let timeout = this.timeouts.get(id); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(id); + } + + this.update(documentId, userId, isEditing); + + if (isEditing) { + timeout = setTimeout(() => { + this.update(documentId, userId, false); + }, USER_PRESENCE_INTERVAL * 2); + this.timeouts.set(id, timeout); + } + } + + get(documentId: string): ?DocumentPresence { + return this.data.get(documentId); + } + + @action + clear() { + this.data.clear(); + } +} diff --git a/app/stores/RootStore.js b/app/stores/RootStore.js index 8a5a4ac3..be89966f 100644 --- a/app/stores/RootStore.js +++ b/app/stores/RootStore.js @@ -7,6 +7,7 @@ import EventsStore from './EventsStore'; import IntegrationsStore from './IntegrationsStore'; import MembershipsStore from './MembershipsStore'; import NotificationSettingsStore from './NotificationSettingsStore'; +import DocumentPresenceStore from './DocumentPresenceStore'; import PoliciesStore from './PoliciesStore'; import RevisionsStore from './RevisionsStore'; import SharesStore from './SharesStore'; @@ -23,6 +24,7 @@ export default class RootStore { integrations: IntegrationsStore; memberships: MembershipsStore; notificationSettings: NotificationSettingsStore; + presence: DocumentPresenceStore; policies: PoliciesStore; revisions: RevisionsStore; shares: SharesStore; @@ -39,6 +41,7 @@ export default class RootStore { this.integrations = new IntegrationsStore(this); this.memberships = new MembershipsStore(this); this.notificationSettings = new NotificationSettingsStore(this); + this.presence = new DocumentPresenceStore(); this.policies = new PoliciesStore(this); this.revisions = new RevisionsStore(this); this.shares = new SharesStore(this); @@ -55,6 +58,7 @@ export default class RootStore { this.integrations.clear(); this.memberships.clear(); this.notificationSettings.clear(); + this.presence.clear(); this.policies.clear(); this.revisions.clear(); this.shares.clear(); diff --git a/app/stores/ViewsStore.js b/app/stores/ViewsStore.js index acc8d953..a7f4d60a 100644 --- a/app/stores/ViewsStore.js +++ b/app/stores/ViewsStore.js @@ -1,5 +1,5 @@ // @flow -import { filter, orderBy } from 'lodash'; +import { filter, find, orderBy } from 'lodash'; import BaseStore from './BaseStore'; import RootStore from './RootStore'; import View from 'models/View'; @@ -18,4 +18,13 @@ export default class ViewsStore extends BaseStore { 'desc' ); } + + touch(documentId: string, userId: string) { + const view = find( + this.orderedData, + view => view.documentId === documentId && view.user.id === userId + ); + if (!view) return; + view.touch(); + } } diff --git a/server/api/views.js b/server/api/views.js index dde61d8b..06780f24 100644 --- a/server/api/views.js +++ b/server/api/views.js @@ -2,7 +2,7 @@ import Router from 'koa-router'; import auth from '../middlewares/authentication'; import { presentView } from '../presenters'; -import { View, Document, Event, User } from '../models'; +import { View, Document, Event } from '../models'; import policy from '../policies'; const { authorize } = policy; @@ -16,16 +16,7 @@ router.post('views.list', auth(), async ctx => { const document = await Document.findByPk(documentId, { userId: user.id }); authorize(user, 'read', document); - const views = await View.findAll({ - where: { documentId }, - order: [['updatedAt', 'DESC']], - include: [ - { - model: User, - paranoid: false, - }, - ], - }); + const views = await View.findByDocument(documentId); ctx.body = { data: views.map(presentView), diff --git a/server/index.js b/server/index.js index 21c2256e..80412e6e 100644 --- a/server/index.js +++ b/server/index.js @@ -1,13 +1,17 @@ // @flow +import { promisify } from 'util'; import http from 'http'; import IO from 'socket.io'; import SocketAuth from 'socketio-auth'; import socketRedisAdapter from 'socket.io-redis'; import { getUserForJWT } from './utils/jwt'; -import { Collection } from './models'; +import { Document, Collection, View } from './models'; +import { client } from './redis'; import app from './app'; import policy from './policies'; +const redisHget = promisify(client.hget).bind(client); +const redisHset = promisify(client.hset).bind(client); const server = http.createServer(app.callback()); let io; @@ -30,6 +34,10 @@ if (process.env.WEBSOCKETS_ENABLED === 'true') { const user = await getUserForJWT(token); socket.client.user = user; + // store the mapping between socket id and user id in redis + // so that it is accessible across multiple server nodes + await redisHset(socket.id, 'userId', user.id); + return callback(null, true); } catch (err) { return callback(err); @@ -37,33 +45,129 @@ if (process.env.WEBSOCKETS_ENABLED === 'true') { }, postAuthenticate: async (socket, data) => { const { user } = socket.client; - // join the rooms associated with the current team - // and user so we can send authenticated events - socket.join(`team-${user.teamId}`); - socket.join(`user-${user.id}`); - // join rooms associated with collections this user + // the rooms associated with the current team + // and user so we can send authenticated events + let rooms = [`team-${user.teamId}`, `user-${user.id}`]; + + // the rooms associated with collections this user // has access to on connection. New collection subscriptions - // are managed from the client as needed + // are managed from the client as needed through the 'join' event const collectionIds = await user.collectionIds(); collectionIds.forEach(collectionId => - socket.join(`collection-${collectionId}`) + rooms.push(`collection-${collectionId}`) ); - // allow the client to request to join rooms based on - // new collections being created. - socket.on('join', async event => { - const collection = await Collection.scope({ - method: ['withMembership', user.id], - }).findByPk(event.roomId); + // join all of the rooms at once + socket.join(rooms); - if (can(user, 'read', collection)) { - socket.join(`collection-${event.roomId}`); + // allow the client to request to join rooms + socket.on('join', async event => { + // user is joining a collection channel, because their permissions have + // changed, granting them access. + if (event.collectionId) { + const collection = await Collection.scope({ + method: ['withMembership', user.id], + }).findByPk(event.collectionId); + + if (can(user, 'read', collection)) { + socket.join(`collection-${event.collectionId}`); + } + } + + // user is joining a document channel, because they have navigated to + // view a document. + if (event.documentId) { + const document = await Document.findByPk(event.documentId, { + userId: user.id, + }); + + if (can(user, 'read', document)) { + const room = `document-${event.documentId}`; + + await View.touch(event.documentId, user.id, event.isEditing); + const editing = await View.findRecentlyEditingByDocument( + event.documentId + ); + + socket.join(room, () => { + // let everyone else in the room know that a new user joined + io.to(room).emit('user.join', { + userId: user.id, + documentId: event.documentId, + isEditing: event.isEditing, + }); + + // let this user know who else is already present in the room + io.in(room).clients(async (err, sockets) => { + if (err) throw err; + + // because a single user can have multiple socket connections we + // need to make sure that only unique userIds are returned. A Map + // makes this easy. + let userIds = new Map(); + for (const socketId of sockets) { + const userId = await redisHget(socketId, 'userId'); + userIds.set(userId, userId); + } + socket.emit('document.presence', { + documentId: event.documentId, + userIds: Array.from(userIds.keys()), + editingIds: editing.map(view => view.userId), + }); + }); + }); + } } }); + // allow the client to request to leave rooms socket.on('leave', event => { - socket.leave(`collection-${event.roomId}`); + if (event.collectionId) { + socket.leave(`collection-${event.collectionId}`); + } + if (event.documentId) { + const room = `document-${event.documentId}`; + socket.leave(room, () => { + io.to(room).emit('user.leave', { + userId: user.id, + documentId: event.documentId, + }); + }); + } + }); + + socket.on('disconnecting', () => { + const rooms = Object.keys(socket.rooms); + + rooms.forEach(room => { + if (room.startsWith('document-')) { + const documentId = room.replace('document-', ''); + io.to(room).emit('user.leave', { + userId: user.id, + documentId, + }); + } + }); + }); + + socket.on('presence', async event => { + const room = `document-${event.documentId}`; + + if (event.documentId && socket.rooms[room]) { + const view = await View.touch( + event.documentId, + user.id, + event.isEditing + ); + view.user = user; + + io.to(room).emit('user.presence', { + userId: user.id, + documentId: event.documentId, + isEditing: event.isEditing, + }); + } }); }, }); diff --git a/server/migrations/20191228031525-edit-presence.js b/server/migrations/20191228031525-edit-presence.js new file mode 100644 index 00000000..9c9cc69c --- /dev/null +++ b/server/migrations/20191228031525-edit-presence.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('views', 'lastEditingAt', { + type: Sequelize.DATE, + allowNull: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('views', 'lastEditingAt'); + } +}; diff --git a/server/models/View.js b/server/models/View.js index 840f3a55..b24cfc08 100644 --- a/server/models/View.js +++ b/server/models/View.js @@ -1,5 +1,8 @@ // @flow -import { DataTypes, sequelize } from '../sequelize'; +import subMilliseconds from 'date-fns/sub_milliseconds'; +import { Op, DataTypes, sequelize } from '../sequelize'; +import { User } from '../models'; +import { USER_PRESENCE_INTERVAL } from '../../shared/constants'; const View = sequelize.define( 'view', @@ -9,6 +12,9 @@ const View = sequelize.define( defaultValue: DataTypes.UUIDV4, primaryKey: true, }, + lastEditingAt: { + type: DataTypes.DATE, + }, count: { type: DataTypes.INTEGER, defaultValue: 1, @@ -33,4 +39,46 @@ View.increment = async where => { return model; }; +View.findByDocument = async documentId => { + return View.findAll({ + where: { documentId }, + order: [['updatedAt', 'DESC']], + include: [ + { + model: User, + paranoid: false, + }, + ], + }); +}; + +View.findRecentlyEditingByDocument = async documentId => { + return View.findAll({ + where: { + documentId, + lastEditingAt: { + [Op.gt]: subMilliseconds(new Date(), USER_PRESENCE_INTERVAL * 2), + }, + }, + order: [['lastEditingAt', 'DESC']], + }); +}; + +View.touch = async (documentId: string, userId: string, isEditing: boolean) => { + const [view] = await View.findOrCreate({ + where: { + userId, + documentId, + }, + }); + + if (isEditing) { + const lastEditingAt = new Date(); + view.lastEditingAt = lastEditingAt; + await view.save(); + } + + return view; +}; + export default View; diff --git a/server/services/websockets.js b/server/services/websockets.js index a4424178..27ff46c7 100644 --- a/server/services/websockets.js +++ b/server/services/websockets.js @@ -164,7 +164,7 @@ export default class Websockets { ) .emit('join', { event: event.name, - roomId: collection.id, + collectionId: collection.id, }); } case 'collections.update': @@ -202,7 +202,7 @@ export default class Websockets { // tell any user clients to connect to the websocket channel for the collection return socketio.to(`user-${event.userId}`).emit('join', { event: event.name, - roomId: event.collectionId, + collectionId: event.collectionId, }); } case 'collections.remove_user': { @@ -216,7 +216,7 @@ export default class Websockets { // tell any user clients to disconnect from the websocket channel for the collection return socketio.to(`user-${event.userId}`).emit('leave', { event: event.name, - roomId: event.collectionId, + collectionId: event.collectionId, }); } default: diff --git a/shared/constants.js b/shared/constants.js new file mode 100644 index 00000000..01a80645 --- /dev/null +++ b/shared/constants.js @@ -0,0 +1,3 @@ +// @flow + +export const USER_PRESENCE_INTERVAL = 5000;