diff --git a/app/components/Actions.js b/app/components/Actions.js
index 3ac624b8..4602050a 100644
--- a/app/components/Actions.js
+++ b/app/components/Actions.js
@@ -8,6 +8,7 @@ export const Action = styled(Flex)`
align-items: center;
padding: 0 0 0 12px;
font-size: 15px;
+ flex-shrink: 0;
a {
color: ${props => props.theme.text};
diff --git a/app/components/Collaborators.js b/app/components/Collaborators.js
index 0fbeaf1c..1d1197c9 100644
--- a/app/components/Collaborators.js
+++ b/app/components/Collaborators.js
@@ -1,64 +1,133 @@
// @flow
import * as React from 'react';
+import { observer, inject } from 'mobx-react';
+import { filter } from 'lodash';
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 ViewsStore from 'stores/ViewsStore';
-type Props = { document: Document };
+const MAX_DISPLAY = 6;
-const Collaborators = ({ document }: Props) => {
- const {
- createdAt,
- updatedAt,
- createdBy,
- updatedBy,
- collaborators,
- } = document;
- let tooltip;
-
- if (createdAt === updatedAt) {
- tooltip = `${createdBy.name} published ${distanceInWordsToNow(
- new Date(createdAt)
- )} ago`;
- } else {
- tooltip = `${updatedBy.name} modified ${distanceInWordsToNow(
- new Date(updatedAt)
- )} ago`;
- }
-
- return (
-
- {collaborators.map(user => (
- 1 ? user.name : tooltip}
- placement="bottom"
- key={user.id}
- >
-
-
-
-
- ))}
-
- );
+type Props = {
+ views: ViewsStore,
+ document: Document,
};
-const AvatarWrapper = styled.div`
- width: 24px;
- height: 24px;
- margin-right: -10px;
+@observer
+class Collaborators extends React.Component {
+ componentDidMount() {
+ this.props.views.fetchPage({ documentId: this.props.document.id });
+ }
+
+ render() {
+ const { document, views } = this.props;
+ const documentViews = views.inDocument(document.id);
+ const {
+ createdAt,
+ updatedAt,
+ createdBy,
+ updatedBy,
+ collaborators,
+ } = document;
+ let tooltip;
+
+ if (createdAt === updatedAt) {
+ tooltip = `${createdBy.name} published ${distanceInWordsToNow(
+ new Date(createdAt)
+ )} ago`;
+ } else {
+ tooltip = `${updatedBy.name} updated ${distanceInWordsToNow(
+ new Date(updatedAt)
+ )} ago`;
+ }
+
+ // 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)
+ );
+
+ // only show the most recent viewers, the rest can overflow
+ const mostRecentViewers = viewersNotCollaborators.slice(
+ 0,
+ MAX_DISPLAY - collaborators.length
+ );
+
+ // if there are too many to display then add a (+X) to the UI
+ const overflow = viewersNotCollaborators.length - mostRecentViewers.length;
+
+ return (
+
+ {overflow > 0 && +{overflow}}
+ {mostRecentViewers.map(({ lastViewedAt, user }) => (
+
+
+
+
+
+ ))}
+ {collaborators.map(user => (
+ 1 ? user.name : tooltip}
+ placement="bottom"
+ >
+
+
+
+
+ ))}
+
+ );
+ }
+}
+
+const StyledTooltip = styled(Tooltip)`
+ margin-right: -8px;
&:first-child {
margin-right: 0;
}
`;
+const Viewer = styled.div`
+ width: 24px;
+ height: 24px;
+ opacity: 0.75;
+`;
+
+const Collaborator = styled.div`
+ width: 24px;
+ height: 24px;
+`;
+
+const More = styled.div`
+ min-width: 30px;
+ height: 24px;
+ border-radius: 12px;
+ background: ${props => props.theme.slate};
+ color: ${props => props.theme.text};
+ border: 2px solid #fff;
+ text-align: center;
+ line-height: 20px;
+ font-size: 11px;
+ font-weight: 600;
+`;
+
const Avatars = styled(Flex)`
align-items: center;
flex-direction: row-reverse;
`;
-export default Collaborators;
+export default inject('views')(Collaborators);
diff --git a/app/models/Document.js b/app/models/Document.js
index e00c9708..d06bf60a 100644
--- a/app/models/Document.js
+++ b/app/models/Document.js
@@ -1,5 +1,5 @@
// @flow
-import { action, set, computed, observable } from 'mobx';
+import { action, set, computed } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
@@ -22,7 +22,6 @@ export default class Document extends BaseModel {
collaborators: User[];
collection: Collection;
collectionId: string;
- firstViewedAt: ?string;
lastViewedAt: ?string;
createdAt: string;
createdBy: User;
@@ -40,9 +39,7 @@ export default class Document extends BaseModel {
url: string;
urlId: string;
shareUrl: ?string;
- views: number;
revision: number;
- @observable embedsDisabled: ?boolean;
constructor(data?: Object = {}, store: *) {
super(data, store);
@@ -144,16 +141,6 @@ export default class Document extends BaseModel {
}
};
- @action
- enableEmbeds = () => {
- this.embedsDisabled = false;
- };
-
- @action
- disableEmbeds = () => {
- this.embedsDisabled = true;
- };
-
@action
star = async () => {
this.starred = true;
@@ -178,8 +165,7 @@ export default class Document extends BaseModel {
@action
view = async () => {
- this.views++;
- await client.post('/views.create', { id: this.id });
+ await client.post('/views.create', { documentId: this.id });
};
@action
diff --git a/app/models/View.js b/app/models/View.js
new file mode 100644
index 00000000..ebb24bf1
--- /dev/null
+++ b/app/models/View.js
@@ -0,0 +1,14 @@
+// @flow
+import BaseModel from './BaseModel';
+import User from './User';
+
+class View extends BaseModel {
+ id: string;
+ documentId: string;
+ firstViewedAt: string;
+ lastViewedAt: string;
+ count: number;
+ user: User;
+}
+
+export default View;
diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js
index fea7430d..7c25988c 100644
--- a/app/scenes/Document/Document.js
+++ b/app/scenes/Document/Document.js
@@ -310,8 +310,7 @@ class DocumentScene extends React.Component {
);
}
- const embedsDisabled =
- document.embedsDisabled || (team && !team.documentEmbeds);
+ const embedsDisabled = team && !team.documentEmbeds;
return (
diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js
index a30404c0..cecc5e23 100644
--- a/app/stores/DocumentsStore.js
+++ b/app/stores/DocumentsStore.js
@@ -13,7 +13,6 @@ import type { FetchOptions, PaginationParams, SearchResult } from 'types';
export default class DocumentsStore extends BaseStore {
@observable recentlyViewedIds: string[] = [];
- @observable recentlyUpdatedIds: string[] = [];
constructor(rootStore: RootStore) {
super(rootStore, Document);
@@ -30,11 +29,7 @@ export default class DocumentsStore extends BaseStore {
@computed
get recentlyUpdated(): * {
- return orderBy(
- compact(this.recentlyUpdatedIds.map(id => this.data.get(id))),
- 'updatedAt',
- 'desc'
- );
+ return orderBy(Array.from(this.data.values()), 'updatedAt', 'desc');
}
createdByUser(userId: string): * {
@@ -139,15 +134,7 @@ export default class DocumentsStore extends BaseStore {
@action
fetchRecentlyUpdated = async (options: ?PaginationParams): Promise<*> => {
- const data = await this.fetchNamedPage('list', options);
-
- runInAction('DocumentsStore#fetchRecentlyUpdated', () => {
- // $FlowFixMe
- this.recentlyUpdatedIds.replace(
- uniq(this.recentlyUpdatedIds.concat(map(data, 'id')))
- );
- });
- return data;
+ return this.fetchNamedPage('list', options);
};
@action
@@ -308,7 +295,6 @@ export default class DocumentsStore extends BaseStore {
runInAction(() => {
this.recentlyViewedIds = without(this.recentlyViewedIds, document.id);
- this.recentlyUpdatedIds = without(this.recentlyUpdatedIds, document.id);
});
const collection = this.getCollectionForDocument(document);
diff --git a/app/stores/RootStore.js b/app/stores/RootStore.js
index 73c12d2c..0026d6a1 100644
--- a/app/stores/RootStore.js
+++ b/app/stores/RootStore.js
@@ -9,6 +9,7 @@ import RevisionsStore from './RevisionsStore';
import SharesStore from './SharesStore';
import UiStore from './UiStore';
import UsersStore from './UsersStore';
+import ViewsStore from './ViewsStore';
export default class RootStore {
apiKeys: ApiKeysStore;
@@ -21,6 +22,7 @@ export default class RootStore {
shares: SharesStore;
ui: UiStore;
users: UsersStore;
+ views: ViewsStore;
constructor() {
this.apiKeys = new ApiKeysStore(this);
@@ -33,6 +35,7 @@ export default class RootStore {
this.shares = new SharesStore(this);
this.ui = new UiStore();
this.users = new UsersStore(this);
+ this.views = new ViewsStore(this);
}
logout() {
@@ -44,5 +47,6 @@ export default class RootStore {
this.revisions.clear();
this.shares.clear();
this.users.clear();
+ this.views.clear();
}
}
diff --git a/app/stores/ViewsStore.js b/app/stores/ViewsStore.js
new file mode 100644
index 00000000..a7ce7420
--- /dev/null
+++ b/app/stores/ViewsStore.js
@@ -0,0 +1,21 @@
+// @flow
+import { filter, orderBy } from 'lodash';
+import BaseStore from './BaseStore';
+import RootStore from './RootStore';
+import View from 'models/View';
+
+export default class ViewsStore extends BaseStore {
+ actions = ['list'];
+
+ constructor(rootStore: RootStore) {
+ super(rootStore, View);
+ }
+
+ inDocument(documentId: string): View[] {
+ return orderBy(
+ filter(this.orderedData, view => view.documentId !== documentId),
+ 'lastViewedAt',
+ 'desc'
+ );
+ }
+}
diff --git a/server/api/documents.js b/server/api/documents.js
index 25a57395..8dd47010 100644
--- a/server/api/documents.js
+++ b/server/api/documents.js
@@ -148,7 +148,7 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
- const views = await Star.findAll({
+ const stars = await Star.findAll({
where: {
userId: user.id,
},
@@ -175,7 +175,7 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
- views.map(view => presentDocument(ctx, view.document))
+ stars.map(star => presentDocument(ctx, star.document))
);
ctx.body = {
diff --git a/server/api/views.js b/server/api/views.js
index 8b095508..a0cc8fd8 100644
--- a/server/api/views.js
+++ b/server/api/views.js
@@ -2,51 +2,47 @@
import Router from 'koa-router';
import auth from '../middlewares/authentication';
import { presentView } from '../presenters';
-import { View, Document } from '../models';
+import { View, Document, User } from '../models';
import policy from '../policies';
const { authorize } = policy;
const router = new Router();
router.post('views.list', auth(), async ctx => {
- const { id } = ctx.body;
- ctx.assertUuid(id, 'id is required');
+ const { documentId } = ctx.body;
+ ctx.assertUuid(documentId, 'documentId is required');
const user = ctx.state.user;
- const document = await Document.findById(id);
+ const document = await Document.findById(documentId);
authorize(user, 'read', document);
const views = await View.findAll({
- where: { documentId: id },
+ where: { documentId },
order: [['updatedAt', 'DESC']],
+ include: [
+ {
+ model: User,
+ paranoid: false,
+ },
+ ],
});
- let users = [];
- let count = 0;
- await Promise.all(
- views.map(async view => {
- count = view.count;
- return users.push(await presentView(ctx, view));
- })
- );
+ const data = views.map(view => presentView(ctx, view));
ctx.body = {
- data: {
- users,
- count,
- },
+ data,
};
});
router.post('views.create', auth(), async ctx => {
- const { id } = ctx.body;
- ctx.assertUuid(id, 'id is required');
+ const { documentId } = ctx.body;
+ ctx.assertUuid(documentId, 'documentId is required');
const user = ctx.state.user;
- const document = await Document.findById(id);
+ const document = await Document.findById(documentId);
authorize(user, 'read', document);
- await View.increment({ documentId: document.id, userId: user.id });
+ await View.increment({ documentId, userId: user.id });
ctx.body = {
success: true,
diff --git a/server/api/views.test.js b/server/api/views.test.js
index ae22fde9..5c35dcf0 100644
--- a/server/api/views.test.js
+++ b/server/api/views.test.js
@@ -1,6 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
+import { View } from '../models';
import { flushdb, seed } from '../test/support';
import { buildUser } from '../test/factories';
@@ -12,16 +13,22 @@ afterAll(server.close);
describe('#views.list', async () => {
it('should return views for a document', async () => {
const { user, document } = await seed();
+ await View.increment({ documentId: document.id, userId: user.id });
+
const res = await server.post('/api/views.list', {
- body: { token: user.getJwtToken(), id: document.id },
+ body: { token: user.getJwtToken(), documentId: document.id },
});
+ const body = await res.json();
+
expect(res.status).toEqual(200);
+ expect(body.data[0].count).toBe(1);
+ expect(body.data[0].user.name).toBe(user.name);
});
it('should require authentication', async () => {
const { document } = await seed();
const res = await server.post('/api/views.list', {
- body: { id: document.id },
+ body: { documentId: document.id },
});
const body = await res.json();
@@ -33,7 +40,7 @@ describe('#views.list', async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post('/api/views.list', {
- body: { token: user.getJwtToken(), id: document.id },
+ body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
@@ -43,7 +50,7 @@ describe('#views.create', async () => {
it('should allow creating a view record for document', async () => {
const { user, document } = await seed();
const res = await server.post('/api/views.create', {
- body: { token: user.getJwtToken(), id: document.id },
+ body: { token: user.getJwtToken(), documentId: document.id },
});
const body = await res.json();
@@ -54,7 +61,7 @@ describe('#views.create', async () => {
it('should require authentication', async () => {
const { document } = await seed();
const res = await server.post('/api/views.create', {
- body: { id: document.id },
+ body: { documentId: document.id },
});
const body = await res.json();
@@ -66,7 +73,7 @@ describe('#views.create', async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post('/api/views.create', {
- body: { token: user.getJwtToken(), id: document.id },
+ body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js
index 6ec30941..c611bc67 100644
--- a/server/pages/developers/Api.js
+++ b/server/pages/developers/Api.js
@@ -560,6 +560,27 @@ export default function Pricing() {
+
+
+
+ List all users that have viewed a document and the overall view
+ count.
+
+
+
+
+
+
+
+
+ Creates a new view for a document. This is documented in the
+ interests of thoroughness however it is recommended that views are
+ not created from outside of the Outline UI.
+
+
+
+
+
diff --git a/server/presenters/document.js b/server/presenters/document.js
index 32a401a1..a0cc240d 100644
--- a/server/presenters/document.js
+++ b/server/presenters/document.js
@@ -32,8 +32,6 @@ async function present(ctx: Object, document: Document, options: ?Options) {
updatedAt: document.updatedAt,
updatedBy: undefined,
publishedAt: document.publishedAt,
- firstViewedAt: undefined,
- lastViewedAt: undefined,
team: document.teamId,
collaborators: [],
starred: !!(document.starred && document.starred.length),
@@ -41,7 +39,6 @@ async function present(ctx: Object, document: Document, options: ?Options) {
pinned: undefined,
collectionId: undefined,
collection: undefined,
- views: undefined,
};
if (!options.isPublic) {
@@ -54,12 +51,6 @@ async function present(ctx: Object, document: Document, options: ?Options) {
data.collection = await presentCollection(ctx, document.collection);
}
- if (document.views && document.views.length === 1) {
- data.views = document.views[0].count;
- data.firstViewedAt = document.views[0].createdAt;
- data.lastViewedAt = document.views[0].updatedAt;
- }
-
// This could be further optimized by using ctx.cache
data.collaborators = await User.findAll({
where: {
diff --git a/server/presenters/view.js b/server/presenters/view.js
index b7fff0fd..ff992a80 100644
--- a/server/presenters/view.js
+++ b/server/presenters/view.js
@@ -1,18 +1,16 @@
// @flow
-import { View, User } from '../models';
+import { View } from '../models';
import { presentUser } from '../presenters';
-async function present(ctx: Object, view: View) {
- let data = {
+function present(ctx: Object, view: View) {
+ return {
+ id: view.id,
+ documentId: view.documentId,
count: view.count,
- user: undefined,
+ firstViewedAt: view.createdAt,
+ lastViewedAt: view.updatedAt,
+ user: presentUser(ctx, view.user),
};
- const user = await ctx.cache.get(
- view.userId,
- async () => await User.findById(view.userId)
- );
- data.user = await presentUser(ctx, user);
- return data;
}
export default present;