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
This commit is contained in:
Tom Moor 2020-01-02 21:17:59 -08:00 committed by GitHub
parent 541e4ebe37
commit 146e4da73b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1013 additions and 484 deletions

View File

@ -18,6 +18,7 @@ export const Action = styled(Flex)`
`;
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;

View File

@ -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<Props> {
};
render() {
const { src, ...rest } = this.props;
const { src, icon, ...rest } = this.props;
return (
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
{...rest}
/>
<AvatarWrapper>
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
{...rest}
/>
{icon && <IconWrapper>{icon}</IconWrapper>}
</AvatarWrapper>
);
}
}
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;

View File

@ -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<Props> {
@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 (
<React.Fragment>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && '(You)'}
<br />
{isPresent
? isEditing ? 'currently editing' : 'currently viewing'
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
</Centered>
}
placement="bottom"
>
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={this.handleOpenProfile}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
</AvatarWrapper>
</Tooltip>
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
</React.Fragment>
);
}
}
@observer
class Collaborators extends React.Component<Props> {
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 (
<Avatars>
{overflow > 0 && <More>+{overflow}</More>}
{mostRecentViewers.map(({ lastViewedAt, user }) => (
<React.Fragment key={user.id}>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong>
<br />
viewed {distanceInWordsToNow(new Date(lastViewedAt))} ago
</Centered>
}
placement="bottom"
>
<Viewer>
<Avatar
src={user.avatarUrl}
onClick={() => this.handleOpenProfile(user.id)}
size={32}
/>
</Viewer>
</Tooltip>
<UserProfile
{mostRecentViewers.map(({ lastViewedAt, user }) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
return (
<AvatarWithPresence
key={user.id}
user={user}
isOpen={this.openProfileId === user.id}
onRequestClose={this.handleCloseProfile}
lastViewedAt={lastViewedAt}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
/>
</React.Fragment>
))}
{collaborators.map(user => (
<React.Fragment key={user.id}>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong>
<br />
{createdAt === updatedAt ? 'published' : 'updated'}{' '}
{updatedBy.id === user.id &&
`${distanceInWordsToNow(new Date(updatedAt))} ago`}
</Centered>
}
placement="bottom"
>
<Collaborator>
<Avatar
src={user.avatarUrl}
onClick={() => this.handleOpenProfile(user.id)}
size={32}
/>
</Collaborator>
</Tooltip>
<UserProfile
user={user}
isOpen={this.openProfileId === user.id}
onRequestClose={this.handleCloseProfile}
/>
</React.Fragment>
))}
);
})}
</Avatars>
);
}
@ -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));

View File

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

View File

@ -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<any>,
};
class RouteSidebarHidden extends React.Component<Props> {
componentDidMount() {
this.props.ui.enableEditMode();
}
componentWillUnmount() {
this.props.ui.disableEditMode();
}
render() {
const { component, ui, ...rest } = this.props;
const Component = component;
return <Route {...rest} render={props => <Component {...props} />} />;
}
}
export default inject('ui')(RouteSidebarHidden);

View File

@ -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<Props> {
socket;
@observable socket;
componentDidMount() {
if (!process.env.WEBSOCKETS_ENABLED) return;
@ -31,6 +37,7 @@ class SocketProvider extends React.Component<Props> {
this.socket = io(window.location.origin, {
path: '/realtime',
});
this.socket.authenticated = false;
const {
auth,
@ -39,164 +46,213 @@ class SocketProvider extends React.Component<Props> {
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);

View File

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

View File

@ -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 = () => <Search notFound />;
@ -75,7 +74,7 @@ export default function Routes() {
component={Zapier}
/>
<Route exact path="/settings/export" component={Export} />
<RouteSidebarHidden
<Route
exact
path="/collections/:id/new"
component={DocumentNew}
@ -92,7 +91,7 @@ export default function Routes() {
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
/>
<RouteSidebarHidden
<Route
exact
path={`/doc/${slug}/edit`}
component={KeyedDocument}

View File

@ -1,7 +1,7 @@
// @flow
import * as React from 'react';
import { inject } from 'mobx-react';
import Document from '.';
import DataLoader from './components/DataLoader';
class KeyedDocument extends React.Component<*> {
componentWillUnmount() {
@ -9,7 +9,15 @@ class KeyedDocument extends React.Component<*> {
}
render() {
return <Document key={this.props.location.pathname} {...this.props} />;
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 <DataLoader key={urlId} {...this.props} />;
}
}

View File

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

View File

@ -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<Props> {
@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 ? <Error404 /> : <ErrorOffline />;
}
const document = this.document;
const revision = this.revision;
if (!document) {
return (
<React.Fragment>
<Loading location={location} />
{this.isEditing && <HideSidebar ui={ui} />}
</React.Fragment>
);
}
const abilities = policies.abilities(document.id);
const key = this.isEditing ? 'editing' : 'read-only';
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
key={key}
document={document}
revision={revision}
abilities={abilities}
location={location}
readOnly={!this.isEditing}
onSearchLink={this.onSearchLink}
/>
</SocketPresence>
);
}
}
export default withRouter(
inject('ui', 'auth', 'documents', 'revisions', 'policies')(DataLoader)
);

View File

@ -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<Props> {
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<Props> {
@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<Props> {
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<Props> {
}, 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<Props> {
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 ? <Error404 /> : <ErrorOffline />;
}
if (!document || !Editor) {
return (
<Container column auto>
<PageTitle
title={location.state ? location.state.title : 'Untitled'}
/>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Container>
);
if (!Editor) {
return <Loading location={location} />;
}
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 (
<ErrorBoundary>
<Container
@ -358,10 +235,7 @@ class DocumentScene extends React.Component<Props> {
<Route
path={`${match.url}/move`}
component={() => (
<DocumentMove
document={document}
onRequestClose={this.goToDocumentCanonical}
/>
<DocumentMove document={document} onRequestClose={this.goBack} />
)}
/>
<PageTitle
@ -371,7 +245,7 @@ class DocumentScene extends React.Component<Props> {
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
{this.isEditing && (
{!readOnly && (
<React.Fragment>
<Prompt
when={this.isDirty && !this.isUploading}
@ -388,14 +262,14 @@ class DocumentScene extends React.Component<Props> {
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<Props> {
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 && (
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<References document={document} />
</ReferencesWrapper>
{readOnly &&
!isShare &&
!revision && (
<React.Fragment>
<MarkAsViewed document={document} />
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<References document={document} />
</ReferencesWrapper>
</React.Fragment>
)}
</MaxWidth>
</Container>
@ -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)
);

View File

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

View File

@ -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<Props> {
editor: ?Editor;

View File

@ -143,13 +143,16 @@ class Header extends React.Component<Props> {
</Title>
)}
<Wrapper align="center" justify="flex-end">
{!isDraft && !isEditing && <Collaborators document={document} />}
{isSaving &&
!isPublishing && (
<Action>
<Status>Saving</Status>
</Action>
)}
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
{!isDraft &&
!isEditing &&
canShareDocuments && (

View File

@ -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<Props> {
componentDidMount() {
this.props.ui.enableEditMode();
}
componentWillUnmount() {
this.props.ui.disableEditMode();
}
render() {
return this.props.children || null;
}
}
export default HideSidebar;

View File

@ -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 (
<Container column auto>
<PageTitle title={location.state ? location.state.title : 'Untitled'} />
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Container>
);
}

View File

@ -12,10 +12,10 @@ const randomValues = Array.from(
() => `${randomInteger(85, 100)}%`
);
const LoadingPlaceholder = (props: Object) => {
const LoadingPlaceholder = () => {
return (
<Wrapper>
<Flex column auto {...props}>
<Flex column auto>
<Mask style={{ width: randomValues[0] }} header />
<Mask style={{ width: randomValues[1] }} />
<Mask style={{ width: randomValues[2] }} />

View File

@ -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<Props> {
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;

View File

@ -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<Props> {
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;
}
}

View File

@ -1,3 +1,3 @@
// @flow
import Document from './Document';
export default Document;
import DataLoader from './components/DataLoader';
export default DataLoader;

View File

@ -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<Props> {
render() {
const { document, onSubmit } = this.props;
if (!document) return null;
return (
<div>

View File

@ -0,0 +1,69 @@
// @flow
import { observable, action } from 'mobx';
import { USER_PRESENCE_INTERVAL } from 'shared/constants';
type DocumentPresence = Map<string, { isEditing: boolean, userId: string }>;
export default class PresenceStore {
@observable data: Map<string, DocumentPresence> = new Map();
timeouts: Map<string, TimeoutID> = 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();
}
}

View File

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

View File

@ -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<View> {
'desc'
);
}
touch(documentId: string, userId: string) {
const view = find(
this.orderedData,
view => view.documentId === documentId && view.user.id === userId
);
if (!view) return;
view.touch();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

3
shared/constants.js Normal file
View File

@ -0,0 +1,3 @@
// @flow
export const USER_PRESENCE_INTERVAL = 5000;