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 <tom.moor@gmail.com>
This commit is contained in:
Guilherme DIniz
2020-09-21 02:32:28 -03:00
committed by GitHub
parent 4ffc04bc5d
commit d487da8f15
12 changed files with 80 additions and 25 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ npm-debug.log
stats.json stats.json
.DS_Store .DS_Store
fakes3/* fakes3/*
.idea

View File

@ -4,8 +4,8 @@ import styled from "styled-components";
const Badge = styled.span` const Badge = styled.span`
margin-left: 10px; margin-left: 10px;
padding: 2px 6px 3px; padding: 2px 6px 3px;
background-color: ${({ primary, theme }) => background-color: ${({ yellow, primary, theme }) =>
primary ? theme.primary : theme.textTertiary}; yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, theme }) => (primary ? theme.white : theme.background)}; color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;

View File

@ -52,6 +52,7 @@ function DocumentMeta({
archivedAt, archivedAt,
deletedAt, deletedAt,
isDraft, isDraft,
lastViewedAt,
} = document; } = document;
// Prevent meta information from displaying if updatedBy is not available. // Prevent meta information from displaying if updatedBy is not available.
@ -103,6 +104,17 @@ function DocumentMeta({
const collection = collections.get(document.collectionId); const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id; const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (!lastViewedAt)
return <Modified highlight={true}>Never viewed</Modified>;
return (
<span>
Viewed <Time dateTime={updatedAt} /> ago
</span>
);
};
return ( return (
<Container align="center" {...rest}> <Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp; {updatedByMe ? "You" : updatedBy.name}&nbsp;
@ -115,6 +127,7 @@ function DocumentMeta({
</strong> </strong>
</span> </span>
)} )}
&nbsp;&nbsp;{timeSinceNow()}
{children} {children}
</Container> </Container>
); );

View File

@ -105,6 +105,7 @@ class DocumentPreview extends React.Component<Props> {
{document.isTemplate && showTemplate && ( {document.isTemplate && showTemplate && (
<Badge primary>Template</Badge> <Badge primary>Template</Badge>
)} )}
{document.isNew && <Badge yellow>New</Badge>}
<SecondaryActions> <SecondaryActions>
{document.isTemplate && {document.isTemplate &&
!document.isArchived && !document.isArchived &&

View File

@ -1,5 +1,6 @@
// @flow // @flow
import addDays from "date-fns/add_days"; import addDays from "date-fns/add_days";
import differenceInDays from "date-fns/difference_in_days";
import invariant from "invariant"; import invariant from "invariant";
import { action, computed, observable, set } from "mobx"; import { action, computed, observable, set } from "mobx";
import parseTitle from "shared/utils/parseTitle"; import parseTitle from "shared/utils/parseTitle";
@ -7,6 +8,7 @@ import unescape from "shared/utils/unescape";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import BaseModel from "models/BaseModel"; import BaseModel from "models/BaseModel";
import User from "models/User"; import User from "models/User";
import View from "./View";
type SaveOptions = { type SaveOptions = {
publish?: boolean, publish?: boolean,
@ -23,7 +25,7 @@ export default class Document extends BaseModel {
collaborators: User[]; collaborators: User[];
collectionId: string; collectionId: string;
lastViewedAt: ?string; @observable lastViewedAt: ?string;
createdAt: string; createdAt: string;
createdBy: User; createdBy: User;
updatedAt: string; updatedAt: string;
@ -47,7 +49,7 @@ export default class Document extends BaseModel {
constructor(fields: Object, store: DocumentsStore) { constructor(fields: Object, store: DocumentsStore) {
super(fields, store); super(fields, store);
if (this.isNew && this.isFromTemplate) { if (this.isNewDocument && this.isFromTemplate) {
this.title = ""; this.title = "";
} }
} }
@ -72,6 +74,14 @@ export default class Document extends BaseModel {
return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt; return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt;
} }
@computed
get isNew(): boolean {
return (
!this.lastViewedAt &&
differenceInDays(new Date(), new Date(this.createdAt)) < 14
);
}
@computed @computed
get isStarred(): boolean { get isStarred(): boolean {
return !!this.store.starredIds.get(this.id); return !!this.store.starredIds.get(this.id);
@ -112,7 +122,7 @@ export default class Document extends BaseModel {
} }
@computed @computed
get isNew(): boolean { get isNewDocument(): boolean {
return this.createdAt === this.updatedAt; return this.createdAt === this.updatedAt;
} }
@ -199,6 +209,11 @@ export default class Document extends BaseModel {
return this.store.rootStore.views.create({ documentId: this.id }); return this.store.rootStore.views.create({ documentId: this.id });
}; };
@action
updateLastViewed = (view: View) => {
this.lastViewedAt = view.lastViewedAt;
};
@action @action
templatize = async () => { templatize = async () => {
return this.store.templatize(this.id); return this.store.templatize(this.id);

View File

@ -15,9 +15,10 @@ class MarkAsViewed extends React.Component<Props> {
componentDidMount() { componentDidMount() {
const { document } = this.props; const { document } = this.props;
this.viewTimeout = setTimeout(() => { this.viewTimeout = setTimeout(async () => {
if (document.publishedAt) { if (document.publishedAt) {
document.view(); const view = await document.view();
document.updateLastViewed(view);
} }
}, MARK_AS_VIEWED_AFTER); }, MARK_AS_VIEWED_AFTER);
} }

View File

@ -114,7 +114,7 @@ export default class BaseStore<T: BaseModel> {
} }
@action @action
async delete(item: T, options?: Object = {}) { async delete(item: T, options: Object = {}) {
if (!this.actions.includes("delete")) { if (!this.actions.includes("delete")) {
throw new Error(`Cannot delete ${this.modelName}`); throw new Error(`Cannot delete ${this.modelName}`);
} }
@ -132,7 +132,7 @@ export default class BaseStore<T: BaseModel> {
} }
@action @action
async fetch(id: string, options?: Object = {}): Promise<*> { async fetch(id: string, options: Object = {}): Promise<*> {
if (!this.actions.includes("info")) { if (!this.actions.includes("info")) {
throw new Error(`Cannot fetch ${this.modelName}`); throw new Error(`Cannot fetch ${this.modelName}`);
} }

View File

@ -399,7 +399,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action @action
fetch = async ( fetch = async (
id: string, id: string,
options?: FetchOptions = {} options: FetchOptions = {}
): Promise<?Document> => { ): Promise<?Document> => {
if (!options.prefetch) this.isFetching = true; if (!options.prefetch) this.isFetching = true;

View File

@ -98,10 +98,12 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
// add the users starred state to the response by default // add the users starred state to the response by default
const starredScope = { method: ["withStarred", user.id] }; const starredScope = { method: ["withStarred", user.id] };
const collectionScope = { method: ["withCollection", user.id] }; const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope( const documents = await Document.scope(
"defaultScope", "defaultScope",
starredScope, starredScope,
collectionScope collectionScope,
viewScope
).findAll({ ).findAll({
where, where,
order: [[sort, direction]], order: [[sort, direction]],
@ -137,10 +139,12 @@ router.post("documents.pinned", auth(), pagination(), async (ctx) => {
const starredScope = { method: ["withStarred", user.id] }; const starredScope = { method: ["withStarred", user.id] };
const collectionScope = { method: ["withCollection", user.id] }; const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope( const documents = await Document.scope(
"defaultScope", "defaultScope",
starredScope, starredScope,
collectionScope collectionScope,
viewScope
).findAll({ ).findAll({
where: { where: {
teamId: user.teamId, teamId: user.teamId,
@ -176,9 +180,11 @@ router.post("documents.archived", auth(), pagination(), async (ctx) => {
const collectionIds = await user.collectionIds(); const collectionIds = await user.collectionIds();
const collectionScope = { method: ["withCollection", user.id] }; const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope( const documents = await Document.scope(
"defaultScope", "defaultScope",
collectionScope collectionScope,
viewScope
).findAll({ ).findAll({
where: { where: {
teamId: user.teamId, teamId: user.teamId,
@ -214,7 +220,8 @@ router.post("documents.deleted", auth(), pagination(), async (ctx) => {
const collectionIds = await user.collectionIds({ paranoid: false }); const collectionIds = await user.collectionIds({ paranoid: false });
const collectionScope = { method: ["withCollection", user.id] }; 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: { where: {
teamId: user.teamId, teamId: user.teamId,
collectionId: collectionIds, collectionId: collectionIds,
@ -349,9 +356,11 @@ router.post("documents.drafts", auth(), pagination(), async (ctx) => {
const collectionIds = await user.collectionIds(); const collectionIds = await user.collectionIds();
const collectionScope = { method: ["withCollection", user.id] }; const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope( const documents = await Document.scope(
"defaultScope", "defaultScope",
collectionScope collectionScope,
viewScope
).findAll({ ).findAll({
where: { where: {
userId: user.id, userId: user.id,

View File

@ -207,11 +207,15 @@ Document.associate = (models) => {
{ model: models.User, as: "updatedBy", paranoid: false }, { model: models.User, as: "updatedBy", paranoid: false },
], ],
}); });
Document.addScope("withViews", (userId) => ({ Document.addScope("withViews", (userId) => {
include: [ if (!userId) return {};
{ model: models.View, as: "views", where: { userId }, required: false },
], return {
})); include: [
{ model: models.View, as: "views", where: { userId }, required: false },
],
};
});
Document.addScope("withStarred", (userId) => ({ Document.addScope("withStarred", (userId) => ({
include: [ include: [
{ model: models.Star, as: "starred", where: { userId }, required: false }, { model: models.Star, as: "starred", where: { userId }, required: false },
@ -222,9 +226,15 @@ Document.associate = (models) => {
Document.findByPk = async function (id, options = {}) { Document.findByPk = async function (id, options = {}) {
// allow default preloading of collection membership if `userId` is passed in find 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. // almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope("withUnpublished", { const scope = this.scope(
method: ["withCollection", options.userId], "withUnpublished",
}); {
method: ["withCollection", options.userId],
},
{
method: ["withViews", options.userId],
}
);
if (isUUID(id)) { if (isUUID(id)) {
return scope.findOne({ return scope.findOne({

View File

@ -2,7 +2,7 @@
import subMilliseconds from "date-fns/sub_milliseconds"; import subMilliseconds from "date-fns/sub_milliseconds";
import { USER_PRESENCE_INTERVAL } from "../../shared/constants"; import { USER_PRESENCE_INTERVAL } from "../../shared/constants";
import { User } from "../models"; import { User } from "../models";
import { Op, DataTypes, sequelize } from "../sequelize"; import { DataTypes, Op, sequelize } from "../sequelize";
const View = sequelize.define( const View = sequelize.define(
"view", "view",

View File

@ -1,6 +1,6 @@
// @flow // @flow
import { takeRight } from "lodash"; import { takeRight } from "lodash";
import { User, Document, Attachment } from "../models"; import { Attachment, Document, User } from "../models";
import { getSignedImageUrl } from "../utils/s3"; import { getSignedImageUrl } from "../utils/s3";
import presentUser from "./user"; import presentUser from "./user";
@ -62,8 +62,13 @@ export default async function present(document: Document, options: ?Options) {
pinned: undefined, pinned: undefined,
collectionId: undefined, collectionId: undefined,
parentDocumentId: undefined, parentDocumentId: undefined,
lastViewedAt: undefined,
}; };
if (!!document.views && document.views.length > 0) {
data.lastViewedAt = document.views[0].updatedAt;
}
if (!options.isPublic) { if (!options.isPublic) {
data.pinned = !!document.pinnedById; data.pinned = !!document.pinnedById;
data.collectionId = document.collectionId; data.collectionId = document.collectionId;