feat: Document presence indicator (#1114)

* Update websockets to allow joining document-based rooms

* dynamic websocket joining

* emit user.join/leave events when entering and exiting document rooms

* presence storage

* feat: frontend presence store

* lint

* UI updates

* First pass editing state

* refactoring

* Timeout per user/doc
lint

* Document data loading refactor to keep Socket mounted

* restore: Mark as viewed functionality
Add display of 'you' to collaborators

* fix: Socket/document remount when document slug changes due to title change

* Revert unneccessary package update

* Move editing ping interval to a shared constant

* fix: Flash of sidebar when loading page directly on editing mode

* separate document and revision loading

* add comments for socket events

* fix: Socket events getting bound multiple times on reconnect

* fix: Clear client side presence state on disconnect

* fix: Don't ignore server side error
Improved documentation

* More comments / why comments

* rename Socket -> SocketPresence

* fix: Handle redis is down
remove unneccessary join

* fix: PR feedback
This commit is contained in:
Tom Moor
2020-01-02 21:17:59 -08:00
committed by GitHub
parent 541e4ebe37
commit 146e4da73b
31 changed files with 1013 additions and 484 deletions

View File

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

View File

@@ -8,6 +8,7 @@ import placeholder from './placeholder.png';
type Props = {
src: string,
size: number,
icon?: React.Node,
};
@observer
@@ -23,18 +24,37 @@ class Avatar extends React.Component<Props> {
};
render() {
const { src, ...rest } = this.props;
const { src, icon, ...rest } = this.props;
return (
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
{...rest}
/>
<AvatarWrapper>
<CircleImg
onError={this.handleError}
src={this.error ? placeholder : src}
{...rest}
/>
{icon && <IconWrapper>{icon}</IconWrapper>}
</AvatarWrapper>
);
}
}
const AvatarWrapper = styled.span`
position: relative;
`;
const IconWrapper = styled.span`
display: flex;
position: absolute;
bottom: -2px;
right: -2px;
background: ${props => props.theme.primary};
border: 2px solid ${props => props.theme.background};
border-radius: 100%;
width: 20px;
height: 20px;
`;
const CircleImg = styled.img`
width: ${props => props.size}px;
height: ${props => props.size}px;

View File

@@ -2,119 +2,136 @@
import * as React from 'react';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import { filter } from 'lodash';
import { sortBy } from 'lodash';
import styled, { withTheme } from 'styled-components';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import styled from 'styled-components';
import Flex from 'shared/components/Flex';
import Avatar from 'components/Avatar';
import Tooltip from 'components/Tooltip';
import Document from 'models/Document';
import User from 'models/User';
import UserProfile from 'scenes/UserProfile';
import ViewsStore from 'stores/ViewsStore';
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
import { EditIcon } from 'outline-icons';
const MAX_DISPLAY = 6;
type Props = {
views: ViewsStore,
presence: DocumentPresenceStore,
document: Document,
currentUserId: string,
};
@observer
class Collaborators extends React.Component<Props> {
@observable openProfileId: ?string;
class AvatarWithPresence extends React.Component<{
user: User,
isPresent: boolean,
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
}> {
@observable isOpen: boolean = false;
handleOpenProfile = () => {
this.isOpen = true;
};
handleCloseProfile = () => {
this.isOpen = false;
};
render() {
const {
user,
lastViewedAt,
isPresent,
isEditing,
isCurrentUser,
} = this.props;
return (
<React.Fragment>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && '(You)'}
<br />
{isPresent
? isEditing ? 'currently editing' : 'currently viewing'
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
</Centered>
}
placement="bottom"
>
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={this.handleOpenProfile}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
</AvatarWrapper>
</Tooltip>
<UserProfile
user={user}
isOpen={this.isOpen}
onRequestClose={this.handleCloseProfile}
/>
</React.Fragment>
);
}
}
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
this.props.views.fetchPage({ documentId: this.props.document.id });
}
handleOpenProfile = (userId: string) => {
this.openProfileId = userId;
};
handleCloseProfile = () => {
this.openProfileId = undefined;
};
render() {
const { document, views } = this.props;
const { document, presence, views, currentUserId } = this.props;
const documentViews = views.inDocument(document.id);
const { createdAt, updatedAt, updatedBy, collaborators } = document;
// filter to only show views that haven't collaborated
const collaboratorIds = collaborators.map(user => user.id);
const viewersNotCollaborators = filter(
documentViews,
view => !collaboratorIds.includes(view.user.id)
);
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map(p => p.userId);
const editingIds = documentPresence
.filter(p => p.isEditing)
.map(p => p.userId);
// only show the most recent viewers, the rest can overflow
const mostRecentViewers = viewersNotCollaborators.slice(
0,
MAX_DISPLAY - collaborators.length
let mostRecentViewers = documentViews.slice(0, MAX_DISPLAY);
// ensure currently present via websocket are always ordered first
mostRecentViewers = sortBy(mostRecentViewers, view =>
presentIds.includes(view.user.id)
);
// if there are too many to display then add a (+X) to the UI
const overflow = viewersNotCollaborators.length - mostRecentViewers.length;
const overflow = documentViews.length - mostRecentViewers.length;
return (
<Avatars>
{overflow > 0 && <More>+{overflow}</More>}
{mostRecentViewers.map(({ lastViewedAt, user }) => (
<React.Fragment key={user.id}>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong>
<br />
viewed {distanceInWordsToNow(new Date(lastViewedAt))} ago
</Centered>
}
placement="bottom"
>
<Viewer>
<Avatar
src={user.avatarUrl}
onClick={() => this.handleOpenProfile(user.id)}
size={32}
/>
</Viewer>
</Tooltip>
<UserProfile
{mostRecentViewers.map(({ lastViewedAt, user }) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
return (
<AvatarWithPresence
key={user.id}
user={user}
isOpen={this.openProfileId === user.id}
onRequestClose={this.handleCloseProfile}
lastViewedAt={lastViewedAt}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
/>
</React.Fragment>
))}
{collaborators.map(user => (
<React.Fragment key={user.id}>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong>
<br />
{createdAt === updatedAt ? 'published' : 'updated'}{' '}
{updatedBy.id === user.id &&
`${distanceInWordsToNow(new Date(updatedAt))} ago`}
</Centered>
}
placement="bottom"
>
<Collaborator>
<Avatar
src={user.avatarUrl}
onClick={() => this.handleOpenProfile(user.id)}
size={32}
/>
</Collaborator>
</Tooltip>
<UserProfile
user={user}
isOpen={this.openProfileId === user.id}
onRequestClose={this.handleCloseProfile}
/>
</React.Fragment>
))}
);
})}
</Avatars>
);
}
@@ -124,21 +141,12 @@ const Centered = styled.div`
text-align: center;
`;
const Viewer = styled.div`
width: 32px;
height: 32px;
opacity: 0.75;
margin-right: -8px;
&:first-child {
margin-right: 0;
}
`;
const Collaborator = styled.div`
const AvatarWrapper = styled.div`
width: 32px;
height: 32px;
margin-right: -8px;
opacity: ${props => (props.isPresent ? 1 : 0.5)};
transition: opacity 250ms ease-in-out;
&:first-child {
margin-right: 0;
@@ -164,4 +172,4 @@ const Avatars = styled(Flex)`
cursor: pointer;
`;
export default inject('views')(Collaborators);
export default inject('views', 'presence')(withTheme(Collaborators));

View File

@@ -137,6 +137,7 @@ const Wrapper = styled(Flex)`
border-left: 1px solid ${props => props.theme.divider};
overflow: scroll;
overscroll-behavior: none;
z-index: 1;
`;
export default inject('documents', 'revisions')(DocumentHistory);

View File

@@ -1,28 +0,0 @@
// @flow
import * as React from 'react';
import { inject } from 'mobx-react';
import { Route } from 'react-router-dom';
import UiStore from 'stores/UiStore';
type Props = {
ui: UiStore,
component: React.ComponentType<any>,
};
class RouteSidebarHidden extends React.Component<Props> {
componentDidMount() {
this.props.ui.enableEditMode();
}
componentWillUnmount() {
this.props.ui.disableEditMode();
}
render() {
const { component, ui, ...rest } = this.props;
const Component = component;
return <Route {...rest} render={props => <Component {...props} />} />;
}
}
export default inject('ui')(RouteSidebarHidden);

View File

@@ -1,29 +1,35 @@
// @flow
import * as React from 'react';
import { inject } from 'mobx-react';
import { observable } from 'mobx';
import { inject, observer } from 'mobx-react';
import { find } from 'lodash';
import io from 'socket.io-client';
import DocumentsStore from 'stores/DocumentsStore';
import CollectionsStore from 'stores/CollectionsStore';
import MembershipsStore from 'stores/MembershipsStore';
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
import PoliciesStore from 'stores/PoliciesStore';
import ViewsStore from 'stores/ViewsStore';
import AuthStore from 'stores/AuthStore';
import UiStore from 'stores/UiStore';
const SocketContext = React.createContext();
export const SocketContext: any = React.createContext();
type Props = {
children: React.Node,
documents: DocumentsStore,
collections: CollectionsStore,
memberships: MembershipsStore,
presence: DocumentPresenceStore,
policies: PoliciesStore,
views: ViewsStore,
auth: AuthStore,
ui: UiStore,
};
@observer
class SocketProvider extends React.Component<Props> {
socket;
@observable socket;
componentDidMount() {
if (!process.env.WEBSOCKETS_ENABLED) return;
@@ -31,6 +37,7 @@ class SocketProvider extends React.Component<Props> {
this.socket = io(window.location.origin, {
path: '/realtime',
});
this.socket.authenticated = false;
const {
auth,
@@ -39,164 +46,213 @@ class SocketProvider extends React.Component<Props> {
collections,
memberships,
policies,
presence,
views,
} = this.props;
if (!auth.token) return;
this.socket.on('connect', () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket.emit('authentication', {
token: auth.token,
});
});
this.socket.on('unauthorized', err => {
ui.showToast(err.message);
throw err;
});
this.socket.on('disconnect', () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
this.socket.on('entities', async event => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId) || {};
this.socket.on('authenticated', () => {
this.socket.authenticated = true;
});
if (event.event === 'documents.delete') {
const document = documents.get(documentId);
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
continue;
this.socket.on('unauthorized', err => {
this.socket.authenticated = false;
ui.showToast(err.message);
throw err;
});
this.socket.on('entities', async event => {
if (event.documentIds) {
for (const documentDescriptor of event.documentIds) {
const documentId = documentDescriptor.id;
let document = documents.get(documentId) || {};
if (event.event === 'documents.delete') {
const document = documents.get(documentId);
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
const { title, updatedAt } = document;
if (updatedAt === documentDescriptor.updatedAt) {
continue;
}
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
if (title !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
// if we already have the latest version (it was us that performed the change)
// the we don't need to update anything either.
const { title, updatedAt } = document;
if (updatedAt === documentDescriptor.updatedAt) {
continue;
}
const existing = find(event.collectionIds, {
id: document.collectionId,
});
// otherwise, grab the latest version of the document
try {
document = await documents.fetch(documentId, {
force: true,
});
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);
return;
}
}
// if the title changed then we need to update the collection also
if (title !== document.title) {
if (!event.collectionIds) {
event.collectionIds = [];
}
const existing = find(event.collectionIds, {
if (!existing) {
event.collectionIds.push({
id: document.collectionId,
});
if (!existing) {
event.collectionIds.push({
id: document.collectionId,
});
}
}
}
// TODO: Move this to the document scene once data loading
// has been refactored to be friendlier there.
if (
auth.user &&
documentId === ui.activeDocumentId &&
document.updatedBy.id !== auth.user.id
) {
ui.showToast(`Document updated by ${document.updatedBy.name}`, {
timeout: 30 * 1000,
action: {
text: 'Refresh',
onClick: () => window.location.reload(),
},
});
// TODO: Move this to the document scene once data loading
// has been refactored to be friendlier there.
if (
auth.user &&
documentId === ui.activeDocumentId &&
document.updatedBy.id !== auth.user.id
) {
ui.showToast(`Document updated by ${document.updatedBy.name}`, {
timeout: 30 * 1000,
action: {
text: 'Refresh',
onClick: () => window.location.reload(),
},
});
}
}
}
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId) || {};
if (event.event === 'collections.delete') {
documents.remove(collectionId);
continue;
}
// if we already have the latest version (it was us that performed
// the change) then we don't need to update anything either.
const { updatedAt } = collection;
if (updatedAt === collectionDescriptor.updatedAt) {
continue;
}
try {
await collections.fetch(collectionId, { force: true });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
collections.remove(collectionId);
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
return;
}
}
}
}
});
if (event.collectionIds) {
for (const collectionDescriptor of event.collectionIds) {
const collectionId = collectionDescriptor.id;
const collection = collections.get(collectionId) || {};
this.socket.on('documents.star', event => {
documents.starredIds.set(event.documentId, true);
});
if (event.event === 'collections.delete') {
documents.remove(collectionId);
continue;
}
this.socket.on('documents.unstar', event => {
documents.starredIds.set(event.documentId, false);
});
// if we already have the latest version (it was us that performed the change)
// the we don't need to update anything either.
const { updatedAt } = collection;
if (updatedAt === collectionDescriptor.updatedAt) {
continue;
}
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on('collections.add_user', event => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, { force: true });
}
try {
await collections.fetch(collectionId, { force: true });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
collections.remove(collectionId);
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
return;
}
}
}
}
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach(document => {
policies.remove(document.id);
});
});
this.socket.on('documents.star', event => {
documents.starredIds.set(event.documentId, true);
});
// received when a user is removed from having access to a collection
// to keep state in sync we must update our UI if the user is us,
// or otherwise just remove any membership state we have for that user.
this.socket.on('collections.remove_user', event => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
});
this.socket.on('documents.unstar', event => {
documents.starredIds.set(event.documentId, false);
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on('join', event => {
this.socket.emit('join', event);
});
this.socket.on('collections.add_user', event => {
if (auth.user && event.userId === auth.user.id) {
collections.fetch(event.collectionId, { force: true });
}
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on('leave', event => {
this.socket.emit('leave', event);
});
// Document policies might need updating as the permission changes
documents.inCollection(event.collectionId).forEach(document => {
policies.remove(document.id);
});
});
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on('document.presence', event => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
this.socket.on('collections.remove_user', event => {
if (auth.user && event.userId === auth.user.id) {
collections.remove(event.collectionId);
memberships.removeCollectionMemberships(event.collectionId);
documents.removeCollectionDocuments(event.collectionId);
} else {
memberships.remove(`${event.userId}-${event.collectionId}`);
}
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on('user.join', event => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on('join', event => {
this.socket.emit('join', event);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on('user.leave', event => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on('leave', event => {
this.socket.emit('leave', event);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on('user.presence', event => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
}
componentWillUnmount() {
if (this.socket) {
this.socket.disconnect();
this.socket.authenticated = false;
}
}
@@ -215,5 +271,7 @@ export default inject(
'documents',
'collections',
'memberships',
'policies'
'presence',
'policies',
'views'
)(SocketProvider);