From d487da8f15e0e4747ad482606448ad33392ed241 Mon Sep 17 00:00:00 2001 From: Guilherme DIniz <64857365+guilherme-diniz@users.noreply.github.com> Date: Mon, 21 Sep 2020 02:32:28 -0300 Subject: [PATCH] feat: Visually differentiate unread documents (#1507) * feat: Visually differentiate unread documents * feat: add document treatment in document preview * fix requested changes Co-authored-by: Tom Moor --- .gitignore | 1 + app/components/Badge.js | 4 +-- app/components/DocumentMeta.js | 13 ++++++++++ .../DocumentPreview/DocumentPreview.js | 1 + app/models/Document.js | 21 ++++++++++++--- .../Document/components/MarkAsViewed.js | 5 ++-- app/stores/BaseStore.js | 4 +-- app/stores/DocumentsStore.js | 2 +- server/api/documents.js | 19 ++++++++++---- server/models/Document.js | 26 +++++++++++++------ server/models/View.js | 2 +- server/presenters/document.js | 7 ++++- 12 files changed, 80 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 1c1d25826..df445813e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ npm-debug.log stats.json .DS_Store fakes3/* +.idea diff --git a/app/components/Badge.js b/app/components/Badge.js index 646fede8d..c4d3f3604 100644 --- a/app/components/Badge.js +++ b/app/components/Badge.js @@ -4,8 +4,8 @@ import styled from "styled-components"; const Badge = styled.span` margin-left: 10px; padding: 2px 6px 3px; - background-color: ${({ primary, theme }) => - primary ? theme.primary : theme.textTertiary}; + background-color: ${({ yellow, primary, theme }) => + yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary}; color: ${({ primary, theme }) => (primary ? theme.white : theme.background)}; border-radius: 4px; font-size: 11px; diff --git a/app/components/DocumentMeta.js b/app/components/DocumentMeta.js index 4f2366b88..08eede428 100644 --- a/app/components/DocumentMeta.js +++ b/app/components/DocumentMeta.js @@ -52,6 +52,7 @@ function DocumentMeta({ archivedAt, deletedAt, isDraft, + lastViewedAt, } = document; // Prevent meta information from displaying if updatedBy is not available. @@ -103,6 +104,17 @@ function DocumentMeta({ const collection = collections.get(document.collectionId); const updatedByMe = auth.user && auth.user.id === updatedBy.id; + const timeSinceNow = () => { + if (!lastViewedAt) + return Never viewed; + + return ( + + Viewed + ); + }; + return ( {updatedByMe ? "You" : updatedBy.name}  @@ -115,6 +127,7 @@ function DocumentMeta({ )} +  • {timeSinceNow()} {children} ); diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 371f44aca..2cd050956 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -105,6 +105,7 @@ class DocumentPreview extends React.Component { {document.isTemplate && showTemplate && ( Template )} + {document.isNew && New} {document.isTemplate && !document.isArchived && diff --git a/app/models/Document.js b/app/models/Document.js index 7538fe927..91818e040 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -1,5 +1,6 @@ // @flow import addDays from "date-fns/add_days"; +import differenceInDays from "date-fns/difference_in_days"; import invariant from "invariant"; import { action, computed, observable, set } from "mobx"; import parseTitle from "shared/utils/parseTitle"; @@ -7,6 +8,7 @@ import unescape from "shared/utils/unescape"; import DocumentsStore from "stores/DocumentsStore"; import BaseModel from "models/BaseModel"; import User from "models/User"; +import View from "./View"; type SaveOptions = { publish?: boolean, @@ -23,7 +25,7 @@ export default class Document extends BaseModel { collaborators: User[]; collectionId: string; - lastViewedAt: ?string; + @observable lastViewedAt: ?string; createdAt: string; createdBy: User; updatedAt: string; @@ -47,7 +49,7 @@ export default class Document extends BaseModel { constructor(fields: Object, store: DocumentsStore) { super(fields, store); - if (this.isNew && this.isFromTemplate) { + if (this.isNewDocument && this.isFromTemplate) { this.title = ""; } } @@ -72,6 +74,14 @@ export default class Document extends BaseModel { return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt; } + @computed + get isNew(): boolean { + return ( + !this.lastViewedAt && + differenceInDays(new Date(), new Date(this.createdAt)) < 14 + ); + } + @computed get isStarred(): boolean { return !!this.store.starredIds.get(this.id); @@ -112,7 +122,7 @@ export default class Document extends BaseModel { } @computed - get isNew(): boolean { + get isNewDocument(): boolean { return this.createdAt === this.updatedAt; } @@ -199,6 +209,11 @@ export default class Document extends BaseModel { return this.store.rootStore.views.create({ documentId: this.id }); }; + @action + updateLastViewed = (view: View) => { + this.lastViewedAt = view.lastViewedAt; + }; + @action templatize = async () => { return this.store.templatize(this.id); diff --git a/app/scenes/Document/components/MarkAsViewed.js b/app/scenes/Document/components/MarkAsViewed.js index 8a12504ba..5c74f388e 100644 --- a/app/scenes/Document/components/MarkAsViewed.js +++ b/app/scenes/Document/components/MarkAsViewed.js @@ -15,9 +15,10 @@ class MarkAsViewed extends React.Component { componentDidMount() { const { document } = this.props; - this.viewTimeout = setTimeout(() => { + this.viewTimeout = setTimeout(async () => { if (document.publishedAt) { - document.view(); + const view = await document.view(); + document.updateLastViewed(view); } }, MARK_AS_VIEWED_AFTER); } diff --git a/app/stores/BaseStore.js b/app/stores/BaseStore.js index eab617e52..a8f97e929 100644 --- a/app/stores/BaseStore.js +++ b/app/stores/BaseStore.js @@ -114,7 +114,7 @@ export default class BaseStore { } @action - async delete(item: T, options?: Object = {}) { + async delete(item: T, options: Object = {}) { if (!this.actions.includes("delete")) { throw new Error(`Cannot delete ${this.modelName}`); } @@ -132,7 +132,7 @@ export default class BaseStore { } @action - async fetch(id: string, options?: Object = {}): Promise<*> { + async fetch(id: string, options: Object = {}): Promise<*> { if (!this.actions.includes("info")) { throw new Error(`Cannot fetch ${this.modelName}`); } diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index ff1aa120e..1e9e1046e 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -399,7 +399,7 @@ export default class DocumentsStore extends BaseStore { @action fetch = async ( id: string, - options?: FetchOptions = {} + options: FetchOptions = {} ): Promise => { if (!options.prefetch) this.isFetching = true; diff --git a/server/api/documents.js b/server/api/documents.js index 6795d9164..6e760b2b4 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -98,10 +98,12 @@ router.post("documents.list", auth(), pagination(), async (ctx) => { // add the users starred state to the response by default const starredScope = { method: ["withStarred", user.id] }; const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", starredScope, - collectionScope + collectionScope, + viewScope ).findAll({ where, order: [[sort, direction]], @@ -137,10 +139,12 @@ router.post("documents.pinned", auth(), pagination(), async (ctx) => { const starredScope = { method: ["withStarred", user.id] }; const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", starredScope, - collectionScope + collectionScope, + viewScope ).findAll({ where: { teamId: user.teamId, @@ -176,9 +180,11 @@ router.post("documents.archived", auth(), pagination(), async (ctx) => { const collectionIds = await user.collectionIds(); const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", - collectionScope + collectionScope, + viewScope ).findAll({ where: { teamId: user.teamId, @@ -214,7 +220,8 @@ router.post("documents.deleted", auth(), pagination(), async (ctx) => { const collectionIds = await user.collectionIds({ paranoid: false }); const collectionScope = { method: ["withCollection", user.id] }; - const documents = await Document.scope(collectionScope).findAll({ + const viewScope = { method: ["withViews", user.id] }; + const documents = await Document.scope(collectionScope, viewScope).findAll({ where: { teamId: user.teamId, collectionId: collectionIds, @@ -349,9 +356,11 @@ router.post("documents.drafts", auth(), pagination(), async (ctx) => { const collectionIds = await user.collectionIds(); const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", - collectionScope + collectionScope, + viewScope ).findAll({ where: { userId: user.id, diff --git a/server/models/Document.js b/server/models/Document.js index e6f222e20..472fcdd97 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -207,11 +207,15 @@ Document.associate = (models) => { { model: models.User, as: "updatedBy", paranoid: false }, ], }); - Document.addScope("withViews", (userId) => ({ - include: [ - { model: models.View, as: "views", where: { userId }, required: false }, - ], - })); + Document.addScope("withViews", (userId) => { + if (!userId) return {}; + + return { + include: [ + { model: models.View, as: "views", where: { userId }, required: false }, + ], + }; + }); Document.addScope("withStarred", (userId) => ({ include: [ { model: models.Star, as: "starred", where: { userId }, required: false }, @@ -222,9 +226,15 @@ Document.associate = (models) => { Document.findByPk = async function (id, options = {}) { // allow default preloading of collection membership if `userId` is passed in find options // almost every endpoint needs the collection membership to determine policy permissions. - const scope = this.scope("withUnpublished", { - method: ["withCollection", options.userId], - }); + const scope = this.scope( + "withUnpublished", + { + method: ["withCollection", options.userId], + }, + { + method: ["withViews", options.userId], + } + ); if (isUUID(id)) { return scope.findOne({ diff --git a/server/models/View.js b/server/models/View.js index b0d88e485..7607aebc8 100644 --- a/server/models/View.js +++ b/server/models/View.js @@ -2,7 +2,7 @@ import subMilliseconds from "date-fns/sub_milliseconds"; import { USER_PRESENCE_INTERVAL } from "../../shared/constants"; import { User } from "../models"; -import { Op, DataTypes, sequelize } from "../sequelize"; +import { DataTypes, Op, sequelize } from "../sequelize"; const View = sequelize.define( "view", diff --git a/server/presenters/document.js b/server/presenters/document.js index 969555abe..c459db85f 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -1,6 +1,6 @@ // @flow import { takeRight } from "lodash"; -import { User, Document, Attachment } from "../models"; +import { Attachment, Document, User } from "../models"; import { getSignedImageUrl } from "../utils/s3"; import presentUser from "./user"; @@ -62,8 +62,13 @@ export default async function present(document: Document, options: ?Options) { pinned: undefined, collectionId: undefined, parentDocumentId: undefined, + lastViewedAt: undefined, }; + if (!!document.views && document.views.length > 0) { + data.lastViewedAt = document.views[0].updatedAt; + } + if (!options.isPublic) { data.pinned = !!document.pinnedById; data.collectionId = document.collectionId;