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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 80 additions and 25 deletions

1
.gitignore vendored
View File

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

View File

@ -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;

View File

@ -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 <Modified highlight={true}>Never viewed</Modified>;
return (
<span>
Viewed <Time dateTime={updatedAt} /> ago
</span>
);
};
return (
<Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp;
@ -115,6 +127,7 @@ function DocumentMeta({
</strong>
</span>
)}
&nbsp;&nbsp;{timeSinceNow()}
{children}
</Container>
);

View File

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

View File

@ -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);

View File

@ -15,9 +15,10 @@ class MarkAsViewed extends React.Component<Props> {
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);
}

View File

@ -114,7 +114,7 @@ export default class BaseStore<T: BaseModel> {
}
@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<T: BaseModel> {
}
@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}`);
}

View File

@ -399,7 +399,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action
fetch = async (
id: string,
options?: FetchOptions = {}
options: FetchOptions = {}
): Promise<?Document> => {
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
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,

View File

@ -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({

View File

@ -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",

View File

@ -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;