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:
@ -18,6 +18,7 @@ export const Action = styled(Flex)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const Separator = styled.div`
|
export const Separator = styled.div`
|
||||||
|
flex-shrink: 0;
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
@ -8,6 +8,7 @@ import placeholder from './placeholder.png';
|
|||||||
type Props = {
|
type Props = {
|
||||||
src: string,
|
src: string,
|
||||||
size: number,
|
size: number,
|
||||||
|
icon?: React.Node,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@ -23,18 +24,37 @@ class Avatar extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { src, ...rest } = this.props;
|
const { src, icon, ...rest } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AvatarWrapper>
|
||||||
<CircleImg
|
<CircleImg
|
||||||
onError={this.handleError}
|
onError={this.handleError}
|
||||||
src={this.error ? placeholder : src}
|
src={this.error ? placeholder : src}
|
||||||
{...rest}
|
{...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`
|
const CircleImg = styled.img`
|
||||||
width: ${props => props.size}px;
|
width: ${props => props.size}px;
|
||||||
height: ${props => props.size}px;
|
height: ${props => props.size}px;
|
||||||
|
@ -2,119 +2,136 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer, inject } from 'mobx-react';
|
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 distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||||
import styled from 'styled-components';
|
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import Avatar from 'components/Avatar';
|
import Avatar from 'components/Avatar';
|
||||||
import Tooltip from 'components/Tooltip';
|
import Tooltip from 'components/Tooltip';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
|
import User from 'models/User';
|
||||||
import UserProfile from 'scenes/UserProfile';
|
import UserProfile from 'scenes/UserProfile';
|
||||||
import ViewsStore from 'stores/ViewsStore';
|
import ViewsStore from 'stores/ViewsStore';
|
||||||
|
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
|
||||||
|
import { EditIcon } from 'outline-icons';
|
||||||
|
|
||||||
const MAX_DISPLAY = 6;
|
const MAX_DISPLAY = 6;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
views: ViewsStore,
|
views: ViewsStore,
|
||||||
|
presence: DocumentPresenceStore,
|
||||||
document: Document,
|
document: Document,
|
||||||
|
currentUserId: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Collaborators extends React.Component<Props> {
|
class AvatarWithPresence extends React.Component<{
|
||||||
@observable openProfileId: ?string;
|
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() {
|
componentDidMount() {
|
||||||
this.props.views.fetchPage({ documentId: this.props.document.id });
|
this.props.views.fetchPage({ documentId: this.props.document.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenProfile = (userId: string) => {
|
|
||||||
this.openProfileId = userId;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleCloseProfile = () => {
|
|
||||||
this.openProfileId = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { document, views } = this.props;
|
const { document, presence, views, currentUserId } = this.props;
|
||||||
const documentViews = views.inDocument(document.id);
|
const documentViews = views.inDocument(document.id);
|
||||||
const { createdAt, updatedAt, updatedBy, collaborators } = document;
|
let documentPresence = presence.get(document.id);
|
||||||
|
documentPresence = documentPresence
|
||||||
// filter to only show views that haven't collaborated
|
? Array.from(documentPresence.values())
|
||||||
const collaboratorIds = collaborators.map(user => user.id);
|
: [];
|
||||||
const viewersNotCollaborators = filter(
|
const presentIds = documentPresence.map(p => p.userId);
|
||||||
documentViews,
|
const editingIds = documentPresence
|
||||||
view => !collaboratorIds.includes(view.user.id)
|
.filter(p => p.isEditing)
|
||||||
);
|
.map(p => p.userId);
|
||||||
|
|
||||||
// only show the most recent viewers, the rest can overflow
|
// only show the most recent viewers, the rest can overflow
|
||||||
const mostRecentViewers = viewersNotCollaborators.slice(
|
let mostRecentViewers = documentViews.slice(0, MAX_DISPLAY);
|
||||||
0,
|
|
||||||
MAX_DISPLAY - collaborators.length
|
// 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
|
// 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 (
|
return (
|
||||||
<Avatars>
|
<Avatars>
|
||||||
{overflow > 0 && <More>+{overflow}</More>}
|
{overflow > 0 && <More>+{overflow}</More>}
|
||||||
{mostRecentViewers.map(({ lastViewedAt, user }) => (
|
{mostRecentViewers.map(({ lastViewedAt, user }) => {
|
||||||
<React.Fragment key={user.id}>
|
const isPresent = presentIds.includes(user.id);
|
||||||
<Tooltip
|
const isEditing = editingIds.includes(user.id);
|
||||||
tooltip={
|
|
||||||
<Centered>
|
return (
|
||||||
<strong>{user.name}</strong>
|
<AvatarWithPresence
|
||||||
<br />
|
key={user.id}
|
||||||
viewed {distanceInWordsToNow(new Date(lastViewedAt))} ago
|
|
||||||
</Centered>
|
|
||||||
}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<Viewer>
|
|
||||||
<Avatar
|
|
||||||
src={user.avatarUrl}
|
|
||||||
onClick={() => this.handleOpenProfile(user.id)}
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
</Viewer>
|
|
||||||
</Tooltip>
|
|
||||||
<UserProfile
|
|
||||||
user={user}
|
user={user}
|
||||||
isOpen={this.openProfileId === user.id}
|
lastViewedAt={lastViewedAt}
|
||||||
onRequestClose={this.handleCloseProfile}
|
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>
|
</Avatars>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -124,21 +141,12 @@ const Centered = styled.div`
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Viewer = styled.div`
|
const AvatarWrapper = styled.div`
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
opacity: 0.75;
|
|
||||||
margin-right: -8px;
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Collaborator = styled.div`
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
margin-right: -8px;
|
margin-right: -8px;
|
||||||
|
opacity: ${props => (props.isPresent ? 1 : 0.5)};
|
||||||
|
transition: opacity 250ms ease-in-out;
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
@ -164,4 +172,4 @@ const Avatars = styled(Flex)`
|
|||||||
cursor: pointer;
|
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};
|
border-left: 1px solid ${props => props.theme.divider};
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
z-index: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject('documents', 'revisions')(DocumentHistory);
|
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
|
// @flow
|
||||||
import * as React from 'react';
|
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 { find } from 'lodash';
|
||||||
import io from 'socket.io-client';
|
import io from 'socket.io-client';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
import MembershipsStore from 'stores/MembershipsStore';
|
import MembershipsStore from 'stores/MembershipsStore';
|
||||||
|
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
|
||||||
import PoliciesStore from 'stores/PoliciesStore';
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
|
import ViewsStore from 'stores/ViewsStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
|
|
||||||
const SocketContext = React.createContext();
|
export const SocketContext: any = React.createContext();
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
memberships: MembershipsStore,
|
memberships: MembershipsStore,
|
||||||
|
presence: DocumentPresenceStore,
|
||||||
policies: PoliciesStore,
|
policies: PoliciesStore,
|
||||||
|
views: ViewsStore,
|
||||||
auth: AuthStore,
|
auth: AuthStore,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
class SocketProvider extends React.Component<Props> {
|
class SocketProvider extends React.Component<Props> {
|
||||||
socket;
|
@observable socket;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (!process.env.WEBSOCKETS_ENABLED) return;
|
if (!process.env.WEBSOCKETS_ENABLED) return;
|
||||||
@ -31,6 +37,7 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
this.socket = io(window.location.origin, {
|
this.socket = io(window.location.origin, {
|
||||||
path: '/realtime',
|
path: '/realtime',
|
||||||
});
|
});
|
||||||
|
this.socket.authenticated = false;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
auth,
|
auth,
|
||||||
@ -39,15 +46,32 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
collections,
|
collections,
|
||||||
memberships,
|
memberships,
|
||||||
policies,
|
policies,
|
||||||
|
presence,
|
||||||
|
views,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
if (!auth.token) return;
|
if (!auth.token) return;
|
||||||
|
|
||||||
this.socket.on('connect', () => {
|
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', {
|
this.socket.emit('authentication', {
|
||||||
token: auth.token,
|
token: auth.token,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('authenticated', () => {
|
||||||
|
this.socket.authenticated = true;
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.on('unauthorized', err => {
|
this.socket.on('unauthorized', err => {
|
||||||
|
this.socket.authenticated = false;
|
||||||
ui.showToast(err.message);
|
ui.showToast(err.message);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
@ -66,8 +90,8 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we already have the latest version (it was us that performed the change)
|
// if we already have the latest version (it was us that performed
|
||||||
// the we don't need to update anything either.
|
// the change) then we don't need to update anything either.
|
||||||
const { title, updatedAt } = document;
|
const { title, updatedAt } = document;
|
||||||
if (updatedAt === documentDescriptor.updatedAt) {
|
if (updatedAt === documentDescriptor.updatedAt) {
|
||||||
continue;
|
continue;
|
||||||
@ -130,8 +154,8 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we already have the latest version (it was us that performed the change)
|
// if we already have the latest version (it was us that performed
|
||||||
// the we don't need to update anything either.
|
// the change) then we don't need to update anything either.
|
||||||
const { updatedAt } = collection;
|
const { updatedAt } = collection;
|
||||||
if (updatedAt === collectionDescriptor.updatedAt) {
|
if (updatedAt === collectionDescriptor.updatedAt) {
|
||||||
continue;
|
continue;
|
||||||
@ -159,6 +183,8 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
documents.starredIds.set(event.documentId, false);
|
documents.starredIds.set(event.documentId, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 => {
|
this.socket.on('collections.add_user', event => {
|
||||||
if (auth.user && event.userId === auth.user.id) {
|
if (auth.user && event.userId === auth.user.id) {
|
||||||
collections.fetch(event.collectionId, { force: true });
|
collections.fetch(event.collectionId, { force: true });
|
||||||
@ -170,6 +196,9 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 => {
|
this.socket.on('collections.remove_user', event => {
|
||||||
if (auth.user && event.userId === auth.user.id) {
|
if (auth.user && event.userId === auth.user.id) {
|
||||||
collections.remove(event.collectionId);
|
collections.remove(event.collectionId);
|
||||||
@ -191,12 +220,39 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
this.socket.on('leave', event => {
|
this.socket.on('leave', event => {
|
||||||
this.socket.emit('leave', event);
|
this.socket.emit('leave', event);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 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 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() {
|
componentWillUnmount() {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.disconnect();
|
this.socket.disconnect();
|
||||||
|
this.socket.authenticated = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,5 +271,7 @@ export default inject(
|
|||||||
'documents',
|
'documents',
|
||||||
'collections',
|
'collections',
|
||||||
'memberships',
|
'memberships',
|
||||||
'policies'
|
'presence',
|
||||||
|
'policies',
|
||||||
|
'views'
|
||||||
)(SocketProvider);
|
)(SocketProvider);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
import { action } from 'mobx';
|
||||||
import BaseModel from './BaseModel';
|
import BaseModel from './BaseModel';
|
||||||
import User from './User';
|
import User from './User';
|
||||||
|
|
||||||
@ -9,6 +10,11 @@ class View extends BaseModel {
|
|||||||
lastViewedAt: string;
|
lastViewedAt: string;
|
||||||
count: number;
|
count: number;
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
|
@action
|
||||||
|
touch() {
|
||||||
|
this.lastViewedAt = new Date().toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default View;
|
export default View;
|
||||||
|
@ -27,7 +27,6 @@ import Error404 from 'scenes/Error404';
|
|||||||
import Layout from 'components/Layout';
|
import Layout from 'components/Layout';
|
||||||
import SocketProvider from 'components/SocketProvider';
|
import SocketProvider from 'components/SocketProvider';
|
||||||
import Authenticated from 'components/Authenticated';
|
import Authenticated from 'components/Authenticated';
|
||||||
import RouteSidebarHidden from 'components/RouteSidebarHidden';
|
|
||||||
import { matchDocumentSlug as slug } from 'utils/routeHelpers';
|
import { matchDocumentSlug as slug } from 'utils/routeHelpers';
|
||||||
|
|
||||||
const NotFound = () => <Search notFound />;
|
const NotFound = () => <Search notFound />;
|
||||||
@ -75,7 +74,7 @@ export default function Routes() {
|
|||||||
component={Zapier}
|
component={Zapier}
|
||||||
/>
|
/>
|
||||||
<Route exact path="/settings/export" component={Export} />
|
<Route exact path="/settings/export" component={Export} />
|
||||||
<RouteSidebarHidden
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/collections/:id/new"
|
path="/collections/:id/new"
|
||||||
component={DocumentNew}
|
component={DocumentNew}
|
||||||
@ -92,7 +91,7 @@ export default function Routes() {
|
|||||||
path={`/doc/${slug}/history/:revisionId?`}
|
path={`/doc/${slug}/history/:revisionId?`}
|
||||||
component={KeyedDocument}
|
component={KeyedDocument}
|
||||||
/>
|
/>
|
||||||
<RouteSidebarHidden
|
<Route
|
||||||
exact
|
exact
|
||||||
path={`/doc/${slug}/edit`}
|
path={`/doc/${slug}/edit`}
|
||||||
component={KeyedDocument}
|
component={KeyedDocument}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { inject } from 'mobx-react';
|
import { inject } from 'mobx-react';
|
||||||
import Document from '.';
|
import DataLoader from './components/DataLoader';
|
||||||
|
|
||||||
class KeyedDocument extends React.Component<*> {
|
class KeyedDocument extends React.Component<*> {
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@ -9,7 +9,15 @@ class KeyedDocument extends React.Component<*> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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 Flex from 'shared/components/Flex';
|
||||||
import {
|
import {
|
||||||
collectionUrl,
|
collectionUrl,
|
||||||
updateDocumentUrl,
|
|
||||||
documentMoveUrl,
|
documentMoveUrl,
|
||||||
documentHistoryUrl,
|
documentHistoryUrl,
|
||||||
documentEditUrl,
|
documentEditUrl,
|
||||||
matchDocumentEdit,
|
|
||||||
} from 'utils/routeHelpers';
|
} from 'utils/routeHelpers';
|
||||||
import { emojiToUrl } from 'utils/emoji';
|
import { emojiToUrl } from 'utils/emoji';
|
||||||
|
|
||||||
import Header from './components/Header';
|
import Header from './Header';
|
||||||
import DocumentMove from './components/DocumentMove';
|
import DocumentMove from './DocumentMove';
|
||||||
import Branding from './components/Branding';
|
import Branding from './Branding';
|
||||||
import KeyboardShortcuts from './components/KeyboardShortcuts';
|
import KeyboardShortcuts from './KeyboardShortcuts';
|
||||||
import References from './components/References';
|
import References from './References';
|
||||||
|
import Loading from './Loading';
|
||||||
|
import Container from './Container';
|
||||||
|
import MarkAsViewed from './MarkAsViewed';
|
||||||
import ErrorBoundary from 'components/ErrorBoundary';
|
import ErrorBoundary from 'components/ErrorBoundary';
|
||||||
import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
|
||||||
import LoadingIndicator from 'components/LoadingIndicator';
|
import LoadingIndicator from 'components/LoadingIndicator';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import Notice from 'shared/components/Notice';
|
import Notice from 'shared/components/Notice';
|
||||||
import Time from 'shared/components/Time';
|
import Time from 'shared/components/Time';
|
||||||
import Error404 from 'scenes/Error404';
|
|
||||||
import ErrorOffline from 'scenes/ErrorOffline';
|
|
||||||
|
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
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 Document from 'models/Document';
|
||||||
import Revision from 'models/Revision';
|
import Revision from 'models/Revision';
|
||||||
|
|
||||||
import schema from './schema';
|
import schema from '../schema';
|
||||||
|
|
||||||
let EditorImport;
|
let EditorImport;
|
||||||
const AUTOSAVE_DELAY = 3000;
|
const AUTOSAVE_DELAY = 3000;
|
||||||
const IS_DIRTY_DELAY = 500;
|
const IS_DIRTY_DELAY = 500;
|
||||||
const MARK_AS_VIEWED_AFTER = 3000;
|
|
||||||
const DISCARD_CHANGES = `
|
const DISCARD_CHANGES = `
|
||||||
You have unsaved changes.
|
You have unsaved changes.
|
||||||
Are you sure you want to discard them?
|
Are you sure you want to discard them?
|
||||||
@ -61,63 +54,38 @@ type Props = {
|
|||||||
match: Object,
|
match: Object,
|
||||||
history: RouterHistory,
|
history: RouterHistory,
|
||||||
location: Location,
|
location: Location,
|
||||||
policies: PoliciesStore,
|
abilities: Object,
|
||||||
documents: DocumentsStore,
|
document: Document,
|
||||||
revisions: RevisionsStore,
|
revision: Revision,
|
||||||
|
readOnly: boolean,
|
||||||
|
onSearchLink: (term: string) => mixed,
|
||||||
auth: AuthStore,
|
auth: AuthStore,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class DocumentScene extends React.Component<Props> {
|
class DocumentScene extends React.Component<Props> {
|
||||||
viewTimeout: TimeoutID;
|
|
||||||
getEditorText: () => string;
|
getEditorText: () => string;
|
||||||
|
|
||||||
@observable editorComponent = EditorImport;
|
@observable editorComponent = EditorImport;
|
||||||
@observable document: ?Document;
|
|
||||||
@observable revision: ?Revision;
|
|
||||||
@observable isUploading: boolean = false;
|
@observable isUploading: boolean = false;
|
||||||
@observable isSaving: boolean = false;
|
@observable isSaving: boolean = false;
|
||||||
@observable isPublishing: boolean = false;
|
@observable isPublishing: boolean = false;
|
||||||
@observable isDirty: boolean = false;
|
@observable isDirty: boolean = false;
|
||||||
@observable isEmpty: boolean = true;
|
@observable isEmpty: boolean = true;
|
||||||
@observable error: ?Error;
|
|
||||||
@observable moveModalOpen: boolean = false;
|
@observable moveModalOpen: boolean = false;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super();
|
super();
|
||||||
this.document = props.documents.getByUrl(props.match.params.documentSlug);
|
|
||||||
this.loadDocument(props);
|
|
||||||
this.loadEditor();
|
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')
|
@keydown('m')
|
||||||
goToMove(ev) {
|
goToMove(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const document = this.document;
|
const { document, abilities } = this.props;
|
||||||
if (!document) return;
|
|
||||||
|
|
||||||
const can = this.props.policies.abilities(document.id);
|
if (abilities.update) {
|
||||||
|
|
||||||
if (can.update) {
|
|
||||||
this.props.history.push(documentMoveUrl(document));
|
this.props.history.push(documentMoveUrl(document));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,120 +93,56 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
@keydown('e')
|
@keydown('e')
|
||||||
goToEdit(ev) {
|
goToEdit(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const document = this.document;
|
const { document, abilities } = this.props;
|
||||||
if (!document) return;
|
|
||||||
|
|
||||||
const can = this.props.policies.abilities(document.id);
|
if (abilities.update) {
|
||||||
|
|
||||||
if (can.update) {
|
|
||||||
this.props.history.push(documentEditUrl(document));
|
this.props.history.push(documentEditUrl(document));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keydown('esc')
|
@keydown('esc')
|
||||||
goBack(ev) {
|
goBack(ev) {
|
||||||
if (this.isEditing) {
|
if (this.props.readOnly) return;
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.props.history.goBack();
|
this.props.history.goBack();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@keydown('h')
|
@keydown('h')
|
||||||
goToHistory(ev) {
|
goToHistory(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (!this.document) return;
|
const { document, revision } = this.props;
|
||||||
|
|
||||||
if (this.revision) {
|
if (revision) {
|
||||||
this.props.history.push(this.document.url);
|
this.props.history.push(document.url);
|
||||||
} else {
|
} else {
|
||||||
this.props.history.push(documentHistoryUrl(this.document));
|
this.props.history.push(documentHistoryUrl(document));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keydown('meta+shift+p')
|
@keydown('meta+shift+p')
|
||||||
onPublish(ev) {
|
onPublish(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (!this.document) return;
|
const { document } = this.props;
|
||||||
if (this.document.publishedAt) return;
|
if (document.publishedAt) return;
|
||||||
this.onSave({ publish: true, done: true });
|
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 () => {
|
loadEditor = async () => {
|
||||||
if (this.editorComponent) return;
|
if (this.editorComponent) return;
|
||||||
|
|
||||||
const Imported = await import('./components/Editor');
|
const Imported = await import('./Editor');
|
||||||
EditorImport = Imported.default;
|
EditorImport = Imported.default;
|
||||||
this.editorComponent = EditorImport;
|
this.editorComponent = EditorImport;
|
||||||
};
|
};
|
||||||
|
|
||||||
get isEditing() {
|
|
||||||
const document = this.document;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
this.props.match.path === matchDocumentEdit ||
|
|
||||||
(document && !document.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
||||||
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
||||||
|
|
||||||
onSave = async (
|
onSave = async (
|
||||||
options: { done?: boolean, publish?: boolean, autosave?: boolean } = {}
|
options: { done?: boolean, publish?: boolean, autosave?: boolean } = {}
|
||||||
) => {
|
) => {
|
||||||
let document = this.document;
|
const { document } = this.props;
|
||||||
if (!document) return;
|
|
||||||
|
|
||||||
// prevent saves when we are already saving
|
// prevent saves when we are already saving
|
||||||
if (document.isSaving) return;
|
if (document.isSaving) return;
|
||||||
@ -257,17 +161,17 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
let isNew = !document.id;
|
let isNew = !document.id;
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
this.isPublishing = !!options.publish;
|
this.isPublishing = !!options.publish;
|
||||||
document = await document.save(options);
|
const savedDocument = await document.save(options);
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
this.isPublishing = false;
|
this.isPublishing = false;
|
||||||
|
|
||||||
if (options.done) {
|
if (options.done) {
|
||||||
this.props.history.push(document.url);
|
this.props.history.push(savedDocument.url);
|
||||||
this.props.ui.setActiveDocument(document);
|
this.props.ui.setActiveDocument(savedDocument);
|
||||||
} else if (isNew) {
|
} else if (isNew) {
|
||||||
this.props.history.push(documentEditUrl(document));
|
this.props.history.push(documentEditUrl(savedDocument));
|
||||||
this.props.ui.setActiveDocument(document);
|
this.props.ui.setActiveDocument(savedDocument);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -276,7 +180,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
}, AUTOSAVE_DELAY);
|
}, AUTOSAVE_DELAY);
|
||||||
|
|
||||||
updateIsDirty = debounce(() => {
|
updateIsDirty = debounce(() => {
|
||||||
const document = this.document;
|
const { document } = this.props;
|
||||||
const editorText = this.getEditorText().trim();
|
const editorText = this.getEditorText().trim();
|
||||||
|
|
||||||
// a single hash is a doc with just an empty title
|
// a single hash is a doc with just an empty title
|
||||||
@ -298,55 +202,28 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
this.autosave();
|
this.autosave();
|
||||||
};
|
};
|
||||||
|
|
||||||
onDiscard = () => {
|
goBack = () => {
|
||||||
let url;
|
let url;
|
||||||
if (this.document && this.document.url) {
|
if (this.props.document.url) {
|
||||||
url = this.document.url;
|
url = this.props.document.url;
|
||||||
} else {
|
} else {
|
||||||
url = collectionUrl(this.props.match.params.id);
|
url = collectionUrl(this.props.match.params.id);
|
||||||
}
|
}
|
||||||
this.props.history.push(url);
|
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() {
|
render() {
|
||||||
const { location, auth, match } = this.props;
|
const { document, revision, readOnly, location, auth, match } = this.props;
|
||||||
const team = auth.team;
|
const team = auth.team;
|
||||||
const Editor = this.editorComponent;
|
const Editor = this.editorComponent;
|
||||||
const document = this.document;
|
|
||||||
const revision = this.revision;
|
|
||||||
const isShare = match.params.shareId;
|
const isShare = match.params.shareId;
|
||||||
|
|
||||||
if (this.error) {
|
if (!Editor) {
|
||||||
return navigator.onLine ? <Error404 /> : <ErrorOffline />;
|
return <Loading location={location} />;
|
||||||
}
|
|
||||||
|
|
||||||
if (!document || !Editor) {
|
|
||||||
return (
|
|
||||||
<Container column auto>
|
|
||||||
<PageTitle
|
|
||||||
title={location.state ? location.state.title : 'Untitled'}
|
|
||||||
/>
|
|
||||||
<CenteredContent>
|
|
||||||
<LoadingPlaceholder />
|
|
||||||
</CenteredContent>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const embedsDisabled = team && !team.documentEmbeds;
|
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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Container
|
<Container
|
||||||
@ -358,10 +235,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
<Route
|
<Route
|
||||||
path={`${match.url}/move`}
|
path={`${match.url}/move`}
|
||||||
component={() => (
|
component={() => (
|
||||||
<DocumentMove
|
<DocumentMove document={document} onRequestClose={this.goBack} />
|
||||||
document={document}
|
|
||||||
onRequestClose={this.goToDocumentCanonical}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@ -371,7 +245,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
|
||||||
|
|
||||||
<Container justify="center" column auto>
|
<Container justify="center" column auto>
|
||||||
{this.isEditing && (
|
{!readOnly && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Prompt
|
<Prompt
|
||||||
when={this.isDirty && !this.isUploading}
|
when={this.isDirty && !this.isUploading}
|
||||||
@ -388,14 +262,14 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
document={document}
|
document={document}
|
||||||
isRevision={!!revision}
|
isRevision={!!revision}
|
||||||
isDraft={document.isDraft}
|
isDraft={document.isDraft}
|
||||||
isEditing={this.isEditing}
|
isEditing={!readOnly}
|
||||||
isSaving={this.isSaving}
|
isSaving={this.isSaving}
|
||||||
isPublishing={this.isPublishing}
|
isPublishing={this.isPublishing}
|
||||||
publishingIsDisabled={
|
publishingIsDisabled={
|
||||||
document.isSaving || this.isPublishing || this.isEmpty
|
document.isSaving || this.isPublishing || this.isEmpty
|
||||||
}
|
}
|
||||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||||
onDiscard={this.onDiscard}
|
goBack={this.goBack}
|
||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -429,21 +303,25 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
disableEmbeds={embedsDisabled}
|
disableEmbeds={embedsDisabled}
|
||||||
onImageUploadStart={this.onImageUploadStart}
|
onImageUploadStart={this.onImageUploadStart}
|
||||||
onImageUploadStop={this.onImageUploadStop}
|
onImageUploadStop={this.onImageUploadStop}
|
||||||
onSearchLink={this.onSearchLink}
|
onSearchLink={this.props.onSearchLink}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
onPublish={this.onPublish}
|
onPublish={this.onPublish}
|
||||||
onCancel={this.onDiscard}
|
onCancel={this.goBack}
|
||||||
readOnly={!this.isEditing || document.isArchived}
|
readOnly={readOnly || document.isArchived}
|
||||||
toc={!revision}
|
toc={!revision}
|
||||||
ui={this.props.ui}
|
ui={this.props.ui}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
/>
|
/>
|
||||||
{!this.isEditing &&
|
{readOnly &&
|
||||||
!isShare && (
|
!isShare &&
|
||||||
|
!revision && (
|
||||||
|
<React.Fragment>
|
||||||
|
<MarkAsViewed document={document} />
|
||||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||||
<References document={document} />
|
<References document={document} />
|
||||||
</ReferencesWrapper>
|
</ReferencesWrapper>
|
||||||
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
</MaxWidth>
|
</MaxWidth>
|
||||||
</Container>
|
</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(
|
export default withRouter(
|
||||||
inject('ui', 'auth', 'documents', 'policies', 'revisions')(DocumentScene)
|
inject('ui', 'auth', 'documents', 'policies', 'revisions')(DocumentScene)
|
||||||
);
|
);
|
@ -21,13 +21,13 @@ import CollectionsStore, { type DocumentPath } from 'stores/CollectionsStore';
|
|||||||
|
|
||||||
const MAX_RESULTS = 8;
|
const MAX_RESULTS = 8;
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
document: Document,
|
document: Document,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
onRequestClose: () => void,
|
onRequestClose: () => void,
|
||||||
};
|
|};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class DocumentMove extends React.Component<Props> {
|
class DocumentMove extends React.Component<Props> {
|
||||||
|
@ -4,10 +4,10 @@ import Editor from 'components/Editor';
|
|||||||
import ClickablePadding from 'components/ClickablePadding';
|
import ClickablePadding from 'components/ClickablePadding';
|
||||||
import plugins from './plugins';
|
import plugins from './plugins';
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
defaultValue?: string,
|
defaultValue?: string,
|
||||||
readOnly?: boolean,
|
readOnly?: boolean,
|
||||||
};
|
|};
|
||||||
|
|
||||||
class DocumentEditor extends React.Component<Props> {
|
class DocumentEditor extends React.Component<Props> {
|
||||||
editor: ?Editor;
|
editor: ?Editor;
|
||||||
|
@ -143,13 +143,16 @@ class Header extends React.Component<Props> {
|
|||||||
</Title>
|
</Title>
|
||||||
)}
|
)}
|
||||||
<Wrapper align="center" justify="flex-end">
|
<Wrapper align="center" justify="flex-end">
|
||||||
{!isDraft && !isEditing && <Collaborators document={document} />}
|
|
||||||
{isSaving &&
|
{isSaving &&
|
||||||
!isPublishing && (
|
!isPublishing && (
|
||||||
<Action>
|
<Action>
|
||||||
<Status>Saving…</Status>
|
<Status>Saving…</Status>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
|
<Collaborators
|
||||||
|
document={document}
|
||||||
|
currentUserId={auth.user ? auth.user.id : undefined}
|
||||||
|
/>
|
||||||
{!isDraft &&
|
{!isDraft &&
|
||||||
!isEditing &&
|
!isEditing &&
|
||||||
canShareDocuments && (
|
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)}%`
|
() => `${randomInteger(85, 100)}%`
|
||||||
);
|
);
|
||||||
|
|
||||||
const LoadingPlaceholder = (props: Object) => {
|
const LoadingPlaceholder = () => {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<Flex column auto {...props}>
|
<Flex column auto>
|
||||||
<Mask style={{ width: randomValues[0] }} header />
|
<Mask style={{ width: randomValues[0] }} header />
|
||||||
<Mask style={{ width: randomValues[1] }} />
|
<Mask style={{ width: randomValues[1] }} />
|
||||||
<Mask style={{ width: randomValues[2] }} />
|
<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
|
// @flow
|
||||||
import Document from './Document';
|
import DataLoader from './components/DataLoader';
|
||||||
export default Document;
|
export default DataLoader;
|
||||||
|
@ -10,7 +10,7 @@ import HelpText from 'components/HelpText';
|
|||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document?: Document,
|
document: Document,
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -34,7 +34,6 @@ class DocumentShare extends React.Component<Props> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { document, onSubmit } = this.props;
|
const { document, onSubmit } = this.props;
|
||||||
if (!document) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 IntegrationsStore from './IntegrationsStore';
|
||||||
import MembershipsStore from './MembershipsStore';
|
import MembershipsStore from './MembershipsStore';
|
||||||
import NotificationSettingsStore from './NotificationSettingsStore';
|
import NotificationSettingsStore from './NotificationSettingsStore';
|
||||||
|
import DocumentPresenceStore from './DocumentPresenceStore';
|
||||||
import PoliciesStore from './PoliciesStore';
|
import PoliciesStore from './PoliciesStore';
|
||||||
import RevisionsStore from './RevisionsStore';
|
import RevisionsStore from './RevisionsStore';
|
||||||
import SharesStore from './SharesStore';
|
import SharesStore from './SharesStore';
|
||||||
@ -23,6 +24,7 @@ export default class RootStore {
|
|||||||
integrations: IntegrationsStore;
|
integrations: IntegrationsStore;
|
||||||
memberships: MembershipsStore;
|
memberships: MembershipsStore;
|
||||||
notificationSettings: NotificationSettingsStore;
|
notificationSettings: NotificationSettingsStore;
|
||||||
|
presence: DocumentPresenceStore;
|
||||||
policies: PoliciesStore;
|
policies: PoliciesStore;
|
||||||
revisions: RevisionsStore;
|
revisions: RevisionsStore;
|
||||||
shares: SharesStore;
|
shares: SharesStore;
|
||||||
@ -39,6 +41,7 @@ export default class RootStore {
|
|||||||
this.integrations = new IntegrationsStore(this);
|
this.integrations = new IntegrationsStore(this);
|
||||||
this.memberships = new MembershipsStore(this);
|
this.memberships = new MembershipsStore(this);
|
||||||
this.notificationSettings = new NotificationSettingsStore(this);
|
this.notificationSettings = new NotificationSettingsStore(this);
|
||||||
|
this.presence = new DocumentPresenceStore();
|
||||||
this.policies = new PoliciesStore(this);
|
this.policies = new PoliciesStore(this);
|
||||||
this.revisions = new RevisionsStore(this);
|
this.revisions = new RevisionsStore(this);
|
||||||
this.shares = new SharesStore(this);
|
this.shares = new SharesStore(this);
|
||||||
@ -55,6 +58,7 @@ export default class RootStore {
|
|||||||
this.integrations.clear();
|
this.integrations.clear();
|
||||||
this.memberships.clear();
|
this.memberships.clear();
|
||||||
this.notificationSettings.clear();
|
this.notificationSettings.clear();
|
||||||
|
this.presence.clear();
|
||||||
this.policies.clear();
|
this.policies.clear();
|
||||||
this.revisions.clear();
|
this.revisions.clear();
|
||||||
this.shares.clear();
|
this.shares.clear();
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { filter, orderBy } from 'lodash';
|
import { filter, find, orderBy } from 'lodash';
|
||||||
import BaseStore from './BaseStore';
|
import BaseStore from './BaseStore';
|
||||||
import RootStore from './RootStore';
|
import RootStore from './RootStore';
|
||||||
import View from 'models/View';
|
import View from 'models/View';
|
||||||
@ -18,4 +18,13 @@ export default class ViewsStore extends BaseStore<View> {
|
|||||||
'desc'
|
'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 Router from 'koa-router';
|
||||||
import auth from '../middlewares/authentication';
|
import auth from '../middlewares/authentication';
|
||||||
import { presentView } from '../presenters';
|
import { presentView } from '../presenters';
|
||||||
import { View, Document, Event, User } from '../models';
|
import { View, Document, Event } from '../models';
|
||||||
import policy from '../policies';
|
import policy from '../policies';
|
||||||
|
|
||||||
const { authorize } = policy;
|
const { authorize } = policy;
|
||||||
@ -16,16 +16,7 @@ router.post('views.list', auth(), async ctx => {
|
|||||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||||
authorize(user, 'read', document);
|
authorize(user, 'read', document);
|
||||||
|
|
||||||
const views = await View.findAll({
|
const views = await View.findByDocument(documentId);
|
||||||
where: { documentId },
|
|
||||||
order: [['updatedAt', 'DESC']],
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
paranoid: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: views.map(presentView),
|
data: views.map(presentView),
|
||||||
|
130
server/index.js
130
server/index.js
@ -1,13 +1,17 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
import { promisify } from 'util';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import IO from 'socket.io';
|
import IO from 'socket.io';
|
||||||
import SocketAuth from 'socketio-auth';
|
import SocketAuth from 'socketio-auth';
|
||||||
import socketRedisAdapter from 'socket.io-redis';
|
import socketRedisAdapter from 'socket.io-redis';
|
||||||
import { getUserForJWT } from './utils/jwt';
|
import { getUserForJWT } from './utils/jwt';
|
||||||
import { Collection } from './models';
|
import { Document, Collection, View } from './models';
|
||||||
|
import { client } from './redis';
|
||||||
import app from './app';
|
import app from './app';
|
||||||
import policy from './policies';
|
import policy from './policies';
|
||||||
|
|
||||||
|
const redisHget = promisify(client.hget).bind(client);
|
||||||
|
const redisHset = promisify(client.hset).bind(client);
|
||||||
const server = http.createServer(app.callback());
|
const server = http.createServer(app.callback());
|
||||||
let io;
|
let io;
|
||||||
|
|
||||||
@ -30,6 +34,10 @@ if (process.env.WEBSOCKETS_ENABLED === 'true') {
|
|||||||
const user = await getUserForJWT(token);
|
const user = await getUserForJWT(token);
|
||||||
socket.client.user = user;
|
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);
|
return callback(null, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return callback(err);
|
return callback(err);
|
||||||
@ -37,33 +45,129 @@ if (process.env.WEBSOCKETS_ENABLED === 'true') {
|
|||||||
},
|
},
|
||||||
postAuthenticate: async (socket, data) => {
|
postAuthenticate: async (socket, data) => {
|
||||||
const { user } = socket.client;
|
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
|
// 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();
|
const collectionIds = await user.collectionIds();
|
||||||
collectionIds.forEach(collectionId =>
|
collectionIds.forEach(collectionId =>
|
||||||
socket.join(`collection-${collectionId}`)
|
rooms.push(`collection-${collectionId}`)
|
||||||
);
|
);
|
||||||
|
|
||||||
// allow the client to request to join rooms based on
|
// join all of the rooms at once
|
||||||
// new collections being created.
|
socket.join(rooms);
|
||||||
|
|
||||||
|
// allow the client to request to join rooms
|
||||||
socket.on('join', async event => {
|
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({
|
const collection = await Collection.scope({
|
||||||
method: ['withMembership', user.id],
|
method: ['withMembership', user.id],
|
||||||
}).findByPk(event.roomId);
|
}).findByPk(event.collectionId);
|
||||||
|
|
||||||
if (can(user, 'read', collection)) {
|
if (can(user, 'read', collection)) {
|
||||||
socket.join(`collection-${event.roomId}`);
|
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.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
|
// @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(
|
const View = sequelize.define(
|
||||||
'view',
|
'view',
|
||||||
@ -9,6 +12,9 @@ const View = sequelize.define(
|
|||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
|
lastEditingAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
},
|
||||||
count: {
|
count: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
defaultValue: 1,
|
defaultValue: 1,
|
||||||
@ -33,4 +39,46 @@ View.increment = async where => {
|
|||||||
return model;
|
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;
|
export default View;
|
||||||
|
@ -164,7 +164,7 @@ export default class Websockets {
|
|||||||
)
|
)
|
||||||
.emit('join', {
|
.emit('join', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
roomId: collection.id,
|
collectionId: collection.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'collections.update':
|
case 'collections.update':
|
||||||
@ -202,7 +202,7 @@ export default class Websockets {
|
|||||||
// tell any user clients to connect to the websocket channel for the collection
|
// tell any user clients to connect to the websocket channel for the collection
|
||||||
return socketio.to(`user-${event.userId}`).emit('join', {
|
return socketio.to(`user-${event.userId}`).emit('join', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
roomId: event.collectionId,
|
collectionId: event.collectionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'collections.remove_user': {
|
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
|
// tell any user clients to disconnect from the websocket channel for the collection
|
||||||
return socketio.to(`user-${event.userId}`).emit('leave', {
|
return socketio.to(`user-${event.userId}`).emit('leave', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
roomId: event.collectionId,
|
collectionId: event.collectionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
default:
|
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