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:
parent
4ffc04bc5d
commit
d487da8f15
|
@ -8,3 +8,4 @@ npm-debug.log
|
|||
stats.json
|
||||
.DS_Store
|
||||
fakes3/*
|
||||
.idea
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
@ -115,6 +127,7 @@ function DocumentMeta({
|
|||
</strong>
|
||||
</span>
|
||||
)}
|
||||
• {timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
Reference in New Issue