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:
parent
541e4ebe37
commit
146e4da73b
@ -18,6 +18,7 @@ export const Action = styled(Flex)`
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
|
@ -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;
|
||||
|
@ -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));
|
||||
|
@ -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);
|
||||
|
@ -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);
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
10
app/scenes/Document/components/Container.js
Normal file
10
app/scenes/Document/components/Container.js
Normal 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;
|
172
app/scenes/Document/components/DataLoader.js
Normal file
172
app/scenes/Document/components/DataLoader.js
Normal 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)
|
||||
);
|
@ -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)
|
||||
);
|
@ -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> {
|
||||
|
@ -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;
|
||||
|
@ -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 && (
|
||||
|
24
app/scenes/Document/components/HideSidebar.js
Normal file
24
app/scenes/Document/components/HideSidebar.js
Normal 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;
|
22
app/scenes/Document/components/Loading.js
Normal file
22
app/scenes/Document/components/Loading.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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] }} />
|
||||
|
34
app/scenes/Document/components/MarkAsViewed.js
Normal file
34
app/scenes/Document/components/MarkAsViewed.js
Normal 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;
|
77
app/scenes/Document/components/SocketPresence.js
Normal file
77
app/scenes/Document/components/SocketPresence.js
Normal 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;
|
||||
}
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
import Document from './Document';
|
||||
export default Document;
|
||||
import DataLoader from './components/DataLoader';
|
||||
export default DataLoader;
|
||||
|
@ -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>
|
||||
|
69
app/stores/DocumentPresenceStore.js
Normal file
69
app/stores/DocumentPresenceStore.js
Normal 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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
138
server/index.js
138
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
14
server/migrations/20191228031525-edit-presence.js
Normal file
14
server/migrations/20191228031525-edit-presence.js
Normal 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');
|
||||
}
|
||||
};
|
@ -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;
|
||||
|
@ -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
3
shared/constants.js
Normal file
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
|
||||
export const USER_PRESENCE_INTERVAL = 5000;
|
Reference in New Issue
Block a user