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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,3 +8,4 @@ npm-debug.log
|
|||||||
stats.json
|
stats.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
fakes3/*
|
fakes3/*
|
||||||
|
.idea
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
{updatedByMe ? "You" : updatedBy.name}
|
||||||
@ -115,6 +127,7 @@ function DocumentMeta({
|
|||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
• {timeSinceNow()}
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
@ -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 &&
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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) => {
|
||||||
|
if (!userId) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
include: [
|
include: [
|
||||||
{ model: models.View, as: "views", where: { userId }, required: false },
|
{ 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(
|
||||||
|
"withUnpublished",
|
||||||
|
{
|
||||||
method: ["withCollection", options.userId],
|
method: ["withCollection", options.userId],
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
method: ["withViews", options.userId],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (isUUID(id)) {
|
if (isUUID(id)) {
|
||||||
return scope.findOne({
|
return scope.findOne({
|
||||||
|
@ -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",
|
||||||
|
@ -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;
|
||||||
|
Reference in New Issue
Block a user