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;