From 7a5480f12fb762e17ee6a50044e48e4d80bffd5b Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sat, 31 May 2025 11:29:55 -0400 Subject: [PATCH] Add option to show modified timestamp on shared docs (#9347) * Add showLastModified option to Share models - Add showLastModified column to shares table with migration - Add showLastModified field to client and server Share models - Add 'Show last modified' toggle in share popover (PublicAccess component) - Update shares.update API route to handle showLastModified field - Include share data in documents.info API response for shared documents - Modify DocumentMeta visibility logic to show timestamp when showLastModified is enabled - Add proper type definitions across component hierarchy - Follow existing patterns used by allowIndexing option * Applied automatic fixes * refactor --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: Tom Moor --- .../Sharing/Document/PublicAccess.tsx | 39 +++++++++++++++++++ app/models/Share.ts | 4 ++ app/scenes/Document/components/Editor.tsx | 34 ++++++++++++---- ...1003217-add-show-last-updated-to-shares.js | 17 ++++++++ server/models/Share.ts | 12 ++++-- server/presenters/document.ts | 6 +++ server/presenters/share.ts | 1 + server/routes/api/documents/documents.ts | 1 + server/routes/api/shares/schema.ts | 1 + server/routes/api/shares/shares.ts | 14 ++++++- shared/i18n/locales/en_US/translation.json | 4 +- 11 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 server/migrations/20250531003217-add-show-last-updated-to-shares.js diff --git a/app/components/Sharing/Document/PublicAccess.tsx b/app/components/Sharing/Document/PublicAccess.tsx index c86ea98880..7982884bce 100644 --- a/app/components/Sharing/Document/PublicAccess.tsx +++ b/app/components/Sharing/Document/PublicAccess.tsx @@ -64,6 +64,19 @@ function PublicAccess({ document, share, sharedParent }: Props) { [share] ); + const handleShowLastModifiedChanged = React.useCallback( + async (event) => { + try { + await share?.save({ + showLastUpdated: event.currentTarget.checked, + }); + } catch (err) { + toast.error(err.message); + } + }, + [share] + ); + const handlePublishedChange = React.useCallback( async (event) => { try { @@ -193,6 +206,32 @@ function PublicAccess({ document, share, sharedParent }: Props) { /> )} + {share?.published && ( + + {t("Show last modified")}  + + + + + } + actions={ + + } + /> + )} + {sharedParent?.published ? ( {copyButton} diff --git a/app/models/Share.ts b/app/models/Share.ts index fd066a98a3..fc7932a6e8 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -60,6 +60,10 @@ class Share extends Model implements Searchable { @observable allowIndexing: boolean; + @Field + @observable + showLastUpdated: boolean; + @observable views: number; diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 793d7b54bf..259f27df77 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -4,6 +4,8 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { mergeRefs } from "react-merge-refs"; import { useHistory, useRouteMatch } from "react-router-dom"; +import styled from "styled-components"; +import Text from "@shared/components/Text"; import { richExtensions, withComments } from "@shared/editor/nodes"; import { TeamPreference } from "@shared/types"; import { colorPalette } from "@shared/utils/collections"; @@ -13,6 +15,7 @@ import { RefHandle } from "~/components/ContentEditable"; import { useDocumentContext } from "~/components/DocumentContext"; import Editor, { Props as EditorProps } from "~/components/Editor"; import Flex from "~/components/Flex"; +import Time from "~/components/Time"; import { withUIExtensions } from "~/editor/extensions"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; @@ -229,16 +232,26 @@ function DocumentEditor(props: Props, ref: React.RefObject) { onBlur={handleBlur} placeholder={t("Untitled")} /> - {!shareId && ( + {shareId ? ( + document.updatedAt ? ( + + {t("Last updated")} + ) : null + ) : ( )} @@ -274,4 +287,9 @@ function DocumentEditor(props: Props, ref: React.RefObject) { ); } +const SharedMeta = styled(Text)` + margin: -12px 0 2em 0; + font-size: 14px; +`; + export default observer(React.forwardRef(DocumentEditor)); diff --git a/server/migrations/20250531003217-add-show-last-updated-to-shares.js b/server/migrations/20250531003217-add-show-last-updated-to-shares.js new file mode 100644 index 0000000000..76cfc9a367 --- /dev/null +++ b/server/migrations/20250531003217-add-show-last-updated-to-shares.js @@ -0,0 +1,17 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("shares", "showLastUpdated", { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("shares", "showLastUpdated"); + } +}; + diff --git a/server/models/Share.ts b/server/models/Share.ts index b3ef01e5ca..80a5e42fc1 100644 --- a/server/models/Share.ts +++ b/server/models/Share.ts @@ -114,6 +114,14 @@ class Share extends IdModel< @Column domain: string | null; + @Default(true) + @Column + allowIndexing: boolean; + + @Default(false) + @Column + showLastUpdated: boolean; + // hooks @BeforeUpdate @@ -185,10 +193,6 @@ class Share extends IdModel< @Column(DataType.UUID) documentId: string; - @Default(true) - @Column - allowIndexing: boolean; - revoke(ctx: APIContext) { const { user } = ctx.context.auth; this.revokedAt = new Date(); diff --git a/server/presenters/document.ts b/server/presenters/document.ts index 42a12f0d6e..1a78c2a835 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -14,6 +14,8 @@ type Options = { includeText?: boolean; /** Always include the data of the document in the payload. */ includeData?: boolean; + + includeUpdatedAt?: boolean; }; async function presentDocument( @@ -75,6 +77,10 @@ async function presentDocument( res.lastViewedAt = document.views[0].updatedAt; } + if (options.isPublic && !options.includeUpdatedAt) { + delete res.updatedAt; + } + if (!options.isPublic) { const source = await document.$get("import"); diff --git a/server/presenters/share.ts b/server/presenters/share.ts index 14acf3ed4a..a05105e1d9 100644 --- a/server/presenters/share.ts +++ b/server/presenters/share.ts @@ -13,6 +13,7 @@ export default function presentShare(share: Share, isAdmin = false) { createdBy: presentUser(share.user), includeChildDocuments: share.includeChildDocuments, allowIndexing: share.allowIndexing, + showLastUpdated: share.showLastUpdated, lastAccessedAt: share.lastAccessedAt || undefined, views: share.views || 0, domain: share.domain, diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index bfeb2185bc..321b69803c 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -579,6 +579,7 @@ router.post( presentDocument(ctx, document, { isPublic, shareId, + includeUpdatedAt: share?.showLastUpdated, }), teamFromCtx?.id === document.teamId ? teamFromCtx : document.$get("team"), ]); diff --git a/server/routes/api/shares/schema.ts b/server/routes/api/shares/schema.ts index 1b6b782e0c..d17d944584 100644 --- a/server/routes/api/shares/schema.ts +++ b/server/routes/api/shares/schema.ts @@ -53,6 +53,7 @@ export const SharesUpdateSchema = BaseSchema.extend({ includeChildDocuments: z.boolean().optional(), published: z.boolean().optional(), allowIndexing: z.boolean().optional(), + showLastUpdated: z.boolean().optional(), urlId: z .string() .regex(UrlHelper.SHARE_URL_SLUG_REGEX, { diff --git a/server/routes/api/shares/shares.ts b/server/routes/api/shares/shares.ts index 1d3eed3bc0..9c20c38be4 100644 --- a/server/routes/api/shares/shares.ts +++ b/server/routes/api/shares/shares.ts @@ -235,8 +235,14 @@ router.post( validate(T.SharesUpdateSchema), transaction(), async (ctx: APIContext) => { - const { id, includeChildDocuments, published, urlId, allowIndexing } = - ctx.input.body; + const { + id, + includeChildDocuments, + published, + urlId, + allowIndexing, + showLastUpdated, + } = ctx.input.body; const { user } = ctx.state.auth; authorize(user, "share", user.team); @@ -267,6 +273,10 @@ router.post( share.allowIndexing = allowIndexing; } + if (showLastUpdated !== undefined) { + share.showLastUpdated = showLastUpdated; + } + await share.saveWithCtx(ctx); ctx.body = { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 56f18f5acd..381ee226fb 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -362,6 +362,8 @@ "Publish to internet": "Publish to internet", "Search engine indexing": "Search engine indexing", "Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page", + "Show last modified": "Show last modified", + "Display the last modified timestamp on the shared page": "Display the last modified timestamp on the shared page", "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future", "{{ userName }} was added to the document": "{{ userName }} was added to the document", "{{ count }} people added to the document": "{{ count }} people added to the document", @@ -655,6 +657,7 @@ "only you": "only you", "person": "person", "people": "people", + "Last updated": "Last updated", "Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…", "Hide contents": "Hide contents", "Show contents": "Show contents", @@ -684,7 +687,6 @@ "{{ count }} characters selected_plural": "{{ count }} characters selected", "Contributors": "Contributors", "Created": "Created", - "Last updated": "Last updated", "Creator": "Creator", "Last edited": "Last edited", "Previously edited": "Previously edited",