Merge pull request #8919 from streetwriters/web/show-monograph-view-count

web: show monograph's view count
This commit is contained in:
Abdullah Atta
2025-11-08 13:07:14 +05:00
committed by GitHub
9 changed files with 416 additions and 333 deletions

View File

@@ -91,7 +91,7 @@ type ToolButton = {
hidden?: boolean;
hideOnMobile?: boolean;
toggled?: boolean;
onClick: () => void;
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
};
export function EditorActionBar() {
@@ -147,11 +147,15 @@ export function EditorActionBar() {
enabled:
activeSession &&
(activeSession.type === "default" || activeSession.type === "readonly"),
onClick: () =>
activeSession &&
(activeSession.type === "default" ||
activeSession.type === "readonly") &&
showPublishView(activeSession.note, "top")
onClick: (e) => {
if (
!activeSession ||
(activeSession.type !== "default" &&
activeSession.type !== "readonly")
)
return;
showPublishView(activeSession.note, e.target as HTMLElement);
}
},
{
title: strings.toc(),

View File

@@ -96,7 +96,7 @@ import {
} from "../icons";
import { Context } from "../list-container/types";
import ListItem from "../list-item";
import { showPublishView } from "../publish-view";
import { PublishDialog } from "../publish-view";
import TimeAgo from "../time-ago";
type NoteProps = NoteResolvedData & {
@@ -480,7 +480,7 @@ export const noteMenuItems: (
title: strings.update(),
icon: Update.path,
onClick: () => {
showPublishView(note, "bottom");
PublishDialog.show({ note });
}
},
{
@@ -499,7 +499,7 @@ export const noteMenuItems: (
]
}
: undefined,
onClick: () => showPublishView(note, "bottom")
onClick: () => PublishDialog.show({ note })
},
{
type: "button",

View File

@@ -18,34 +18,39 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { Flex, Text, Button, Link } from "@theme-ui/components";
import { Copy } from "../icons";
import Toggle from "../toggle";
import Field from "../field";
import { Flex, Text, Button, Link, Switch, Input } from "@theme-ui/components";
import { Loading, Refresh } from "../icons";
import { db } from "../../common/db";
import { writeText } from "clipboard-polyfill";
import { ScopedThemeProvider } from "../theme-provider";
import { showToast } from "../../utils/toast";
import { EV, EVENTS, hosts } from "@notesnook/core";
import { EV, EVENTS, hosts, MonographAnalytics } from "@notesnook/core";
import { useStore } from "../../stores/monograph-store";
import ReactModal from "react-modal";
import { DialogButton } from "../dialog";
import { Note } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import {
getFormattedDate,
useIsFeatureAvailable,
usePromise
} from "@notesnook/common";
import { createRoot, Root } from "react-dom/client";
import { PopupPresenter } from "@notesnook/ui";
import { BaseDialogProps, DialogManager } from "../../common/dialog-manager";
import Dialog from "../../components/dialog";
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
type PublishViewProps = {
note: Note;
monograph?: ResolvedMonograph;
onClose: (result: boolean) => void;
};
function PublishView(props: PublishViewProps) {
const { note, onClose } = props;
const [publishId, setPublishId] = useState<string | undefined>(
db.monographs.monograph(note.id)
const [selfDestruct, setSelfDestruct] = useState(
props.monograph?.selfDestruct
);
const [isPasswordProtected, setIsPasswordProtected] = useState(false);
const [selfDestruct, setSelfDestruct] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [status, setStatus] = useState<{
action: "publish" | "unpublish" | "analytics";
}>();
const [processingStatus, setProcessingStatus] = useState<{
total?: number;
current: number;
@@ -53,29 +58,12 @@ function PublishView(props: PublishViewProps) {
const passwordInput = useRef<HTMLInputElement>(null);
const publishNote = useStore((store) => store.publish);
const unpublishNote = useStore((store) => store.unpublish);
useEffect(() => {
if (!publishId) return;
(async () => {
const monographId = db.monographs.monograph(note.id);
if (monographId) {
const monograph = await db.monographs.get(monographId);
if (!monograph) return;
setPublishId(monographId);
setIsPasswordProtected(!!monograph.password);
setSelfDestruct(!!monograph.selfDestruct);
if (monograph.password) {
const password = await db.monographs.decryptPassword(
monograph.password
);
if (passwordInput.current) {
passwordInput.current.value = password;
}
}
}
})();
}, [publishId, isPublishing, note.id]);
const [monograph, setMonograph] = useState(props.monograph);
const monographAnalytics = useIsFeatureAvailable("monographAnalytics");
const analytics = usePromise(async () => {
if (!monographAnalytics?.isAllowed || !monograph) return { totalViews: 0 };
return await db.monographs.analytics(monograph?.id);
}, [monograph?.id, monographAnalytics]);
useEffect(() => {
const fileDownloadedEvent = EV.subscribe(
@@ -93,175 +81,182 @@ function PublishView(props: PublishViewProps) {
}, [note.id]);
return (
<ScopedThemeProvider
scope="dialog"
injectCssVars
sx={{
width: ["100%", 350, 350],
border: "1px solid",
borderColor: "border",
borderRadius: "dialog",
flexDirection: "column",
overflow: "hidden"
}}
bg="background"
>
<Flex p={2} sx={{ flexDirection: "column" }}>
<Text
variant="body"
sx={{ fontSize: "title", fontWeight: "bold", color: "accent" }}
<>
{monograph?.id ? (
<Flex
sx={{
border: "1px solid var(--border)",
borderRadius: "default",
alignItems: "center",
justifyContent: "space-between"
}}
>
{note.title}
</Text>
{isPublishing ? (
<Flex
my={50}
<Link
variant="text.body"
as="a"
target="_blank"
href={`${hosts.MONOGRAPH_HOST}/${monograph?.id}`}
sx={{
flexDirection: "column",
alignItems: "center",
justifyContent: "center"
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
overflow: "hidden",
px: 1
}}
>
<Text>{strings.pleaseWait()}...</Text>
{processingStatus && (
<Text variant="subBody" mt={1}>
{strings.downloadingImages()} ({processingStatus.current}/
{processingStatus.total})
</Text>
)}
{`${hosts.MONOGRAPH_HOST}/${monograph?.id}`}
</Link>
<Button
variant="secondary"
className="copyPublishLink"
sx={{ flexShrink: 0, m: 0 }}
onClick={() => {
writeText(`${hosts.MONOGRAPH_HOST}/${monograph?.id}`);
}}
>
{strings.copy()}
</Button>
</Flex>
) : null}
<Flex
sx={{
flexDirection: "column",
border: "1px solid var(--border)",
borderRadius: "default"
}}
>
{monograph?.publishedAt ? (
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 1,
height: 30
}}
>
<Text variant="body">{strings.publishedAt()}</Text>
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
{getFormattedDate(monograph?.publishedAt, "date-time")}
</Text>
</Flex>
) : (
<>
{publishId ? (
<Flex
mt={1}
sx={{
flexDirection: "column",
overflow: "hidden"
}}
>
<Text
variant="body"
sx={{ fontWeight: "bold", color: "paragraph" }}
>
{strings.publishedAt()}
</Text>
<Flex
sx={{
bg: "var(--background-secondary)",
mt: 1,
p: 1,
borderRadius: "default",
alignItems: "center",
justifyContent: "center"
}}
>
<Link
variant="text.body"
as="a"
target="_blank"
href={`${hosts.MONOGRAPH_HOST}/${publishId}`}
) : null}
{monograph?.id ? (
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 1,
height: 30
}}
>
<Text variant="body">{strings.views()}</Text>
{monographAnalytics?.isAllowed ? (
analytics.status === "fulfilled" ? (
<Flex sx={{ alignItems: "center", gap: 1 }}>
<Text
variant="body"
sx={{
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
overflow: "hidden",
mr: 2
color: "paragraph-secondary"
}}
>
{`${hosts.MONOGRAPH_HOST}/${publishId}`}
</Link>
{analytics.value.totalViews}
</Text>
<Button
variant="anchor"
className="copyPublishLink"
onClick={() => {
writeText(`${hosts.MONOGRAPH_HOST}/${publishId}`);
variant="tertiary"
onClick={async () => {
try {
setStatus({ action: "analytics" });
analytics.refresh();
} finally {
setStatus(undefined);
}
}}
>
<Copy size={20} color="accent" />
<Refresh
size={14}
rotate={status?.action === "analytics"}
/>
</Button>
</Flex>
</Flex>
) : (
<Text
variant="body"
sx={{
color: "paragraph"
}}
) : (
<Loading size={14} />
)
) : monographAnalytics ? (
<Button
variant="anchor"
onClick={() =>
UpgradeDialog.show({ feature: monographAnalytics })
}
>
{strings.monographDesc()}
</Text>
)}
<Toggle
title={strings.monographSelfDestructHeading()}
tip={strings.monographSelfDestructDesc()}
isToggled={selfDestruct}
onToggled={() => setSelfDestruct((s) => !s)}
/>
<Toggle
title={strings.monographPassHeading()}
tip={strings.monographPassDesc()}
isToggled={isPasswordProtected}
onToggled={() => setIsPasswordProtected((s) => !s)}
/>
{isPasswordProtected && (
<Field
inputRef={passwordInput}
autoFocus
type="password"
id="publishPassword"
placeholder={strings.enterPassword()}
required
sx={{ my: 1 }}
/>
)}
</>
)}
{strings.upgrade()}
</Button>
) : null}
</Flex>
) : null}
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
px: 1,
height: 30,
"& label": { width: "auto", flexShrink: 0 }
}}
onClick={() => setSelfDestruct((s) => !s)}
title={strings.monographSelfDestructDesc()}
>
<Text variant="body">{strings.monographSelfDestructHeading()}</Text>
<Switch
sx={{
m: 0,
bg: selfDestruct ? "accent" : "icon-secondary",
flexShrink: 0,
scale: 0.75
}}
checked={selfDestruct}
onClick={(e) => e.stopPropagation()}
/>
</Flex>
<Flex
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 1,
height: 30,
"& label": { width: "auto", flexShrink: 0 }
}}
title={strings.monographPassDesc()}
>
<Text variant="body">{strings.monographPassHeading()}</Text>
<Input
ref={passwordInput}
type="password"
variant="clean"
placeholder={strings.noPassword()}
defaultValue={monograph?.password}
sx={{ textAlign: "right", p: 0 }}
/>
</Flex>
</Flex>
<Flex
bg="var(--background-secondary)"
p={1}
px={2}
sx={{ alignItems: "center", justifyContent: "end" }}
sx={{
flexDirection: "column",
border: "1px solid var(--border)",
borderRadius: "default",
overflow: "hidden"
}}
>
<DialogButton
color="accent"
onClick={async () => {
try {
setIsPublishing(true);
const password = passwordInput.current?.value;
const publishId = await publishNote(note.id, {
selfDestruct,
password
});
setPublishId(publishId);
onClose(true);
showToast("success", strings.actions.published.note(1));
} catch (e) {
console.error(e);
showToast(
"error",
`${strings.actionErrors.published.note(1)}: ${
(e as Error).message
}`
);
} finally {
setIsPublishing(false);
}
}}
loading={isPublishing}
text={publishId ? strings.update() : strings.publish()}
/>
{publishId && (
<DialogButton
color="red"
{monograph?.id && (
<Button
variant="errorSecondary"
onClick={async () => {
try {
setIsPublishing(true);
setStatus({ action: "unpublish" });
await unpublishNote(note.id);
setPublishId(undefined);
onClose(true);
showToast("success", strings.actions.unpublished.note(1));
} catch (e) {
@@ -272,74 +267,177 @@ function PublishView(props: PublishViewProps) {
(e as Error).message
);
} finally {
setIsPublishing(false);
setStatus(undefined);
}
}}
text={"Unpublish"}
/>
sx={{ textAlign: "left", borderRadius: "none" }}
disabled={!!status}
>
{status?.action === "unpublish" ? (
<Loading size={16} />
) : (
strings.unpublish()
)}
</Button>
)}
<Button
variant={monograph?.id ? "secondary" : "accentSecondary"}
onClick={async () => {
try {
setStatus({ action: "publish" });
const password = passwordInput.current?.value;
<DialogButton
data-test-id="dialog-no"
onClick={() => {
onClose(false);
await publishNote(note.id, {
selfDestruct,
password
});
setMonograph(await resolveMonograph(note.id));
showToast("success", strings.actions.published.note(1));
} catch (e) {
console.error(e);
showToast(
"error",
`${strings.actionErrors.published.note(1)}: ${
(e as Error).message
}`
);
} finally {
setStatus(undefined);
}
}}
color="paragraph"
text="Cancel"
/>
sx={{ textAlign: "left", borderRadius: "none" }}
disabled={!!status}
>
{status?.action === "publish" ? (
<Loading size={16} />
) : monograph?.id ? (
strings.update()
) : (
strings.publish()
)}
</Button>
</Flex>
</ScopedThemeProvider>
{processingStatus ? (
<Text variant="subBody">
{strings.downloadingImages()} {processingStatus.current}/
{processingStatus.total || "?"}
</Text>
) : null}
</>
);
}
export default PublishView;
export function showPublishView(note: Note, location = "top") {
const root = document.getElementById("dialogContainer");
let root: Root | null = null;
if (root) {
return new Promise((resolve) => {
const perform = (result: boolean) => {
ReactDOM.unmountComponentAtNode(root);
closePublishView();
resolve(result);
};
ReactDOM.render(
<ReactModal
isOpen
onRequestClose={() => perform(false)}
preventScroll={false}
shouldCloseOnOverlayClick
shouldCloseOnEsc
shouldFocusAfterRender
shouldReturnFocusAfterClose
style={{
overlay: { backgroundColor: "transparent", zIndex: 999 },
content: {
padding: 0,
top: location === "top" ? 60 : undefined,
right: location === "top" ? 10 : undefined,
bottom: location === "bottom" ? 0 : undefined,
left: location === "bottom" ? 0 : undefined,
background: "transparent",
border: "none",
borderRadius: 0,
boxShadow: "0px 0px 15px 0px #00000011"
}
}}
>
<PublishView note={note} onClose={perform} />
</ReactModal>,
root
);
});
}
return Promise.reject("No element with id 'dialogContainer'");
function close() {
root?.unmount();
root = null;
}
function closePublishView() {
const root = document.getElementById("dialogContainer");
if (root) {
root.innerHTML = "";
}
export async function showPublishView(note: Note, target?: HTMLElement) {
const rootElement = document.getElementById("dialogContainer");
if (!rootElement) return;
if (root) return close();
const monograph = await resolveMonograph(note.id);
root = createRoot(rootElement);
root.render(
<PopupPresenter
isOpen
onClose={() => close()}
position={{
target,
location: "below",
isTargetAbsolute: true,
yOffset: 10,
xOffset: -10
}}
sx={{
boxShadow: "0px 0px 15px 0px #00000011"
}}
scope="dialog"
>
<Flex
p={2}
sx={{
flexDirection: "column",
gap: 1,
bg: "background",
width: ["100%", 350, 350],
border: "1px solid",
borderColor: "border",
borderRadius: "dialog",
overflow: "hidden"
}}
>
<Text variant="subtitle">{strings.publishToTheWeb()}</Text>
<Text variant="subBody">{strings.monographDesc()}</Text>
<PublishView
note={note}
monograph={monograph}
onClose={() => close()}
/>
</Flex>
</PopupPresenter>
);
}
export const PublishDialog = DialogManager.register(function PublishDialog(
props: BaseDialogProps<boolean> & { note: Note }
) {
const monograph = usePromise(
() => resolveMonograph(props.note.id),
[props.note.id]
);
if (monograph.status !== "fulfilled") return null;
return (
<Dialog
isOpen={true}
title={strings.publishToTheWeb()}
description={strings.monographDesc()}
width={400}
onClose={() => props.onClose(false)}
>
<Flex
sx={{
flexDirection: "column",
gap: 1,
mb: 3
}}
>
<PublishView
note={props.note}
monograph={monograph.value}
onClose={props.onClose}
/>
</Flex>
</Dialog>
);
});
type ResolvedMonograph = {
id: string;
selfDestruct: boolean;
publishedAt?: number;
password?: string;
};
async function resolveMonograph(
monographId: string
): Promise<ResolvedMonograph | undefined> {
const monograph = await db.monographs.get(monographId);
if (!monograph) return;
return {
id: monographId,
selfDestruct: !!monograph.selfDestruct,
publishedAt: monograph.datePublished,
password: monograph.password
? await db.monographs.decryptPassword(monograph.password)
: undefined
};
}

View File

@@ -1,82 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useCallback, useState } from "react";
import Tip from "../tip";
import { Flex, Switch } from "@theme-ui/components";
import { Loading } from "../icons";
function Toggle(props) {
const {
title,
onTip,
offTip,
isToggled,
onToggled,
onlyIf,
testId,
disabled,
tip
} = props;
const [isLoading, setIsLoading] = useState(false);
const onClick = useCallback(async () => {
setIsLoading(true);
try {
await onToggled();
} finally {
setIsLoading(false);
}
}, [onToggled]);
if (onlyIf === false) return null;
return (
<Flex
onClick={disabled ? null : onClick}
data-test-id={testId}
py={2}
sx={{
opacity: disabled ? 0.7 : 1,
cursor: "pointer",
borderBottom: "1px solid",
borderBottomColor: "separator",
":hover": { borderBottomColor: "accent" },
alignItems: "center",
justifyContent: "space-between",
"& > label": { width: "auto", flexShrink: 0 }
}}
>
<Tip
text={title}
tip={tip ? tip : isToggled ? onTip : offTip}
sx={{ mr: 2 }}
/>
{isLoading ? (
<Loading size={18} />
) : (
<Switch
onClick={disabled ? null : onClick}
checked={isToggled}
sx={{ m: 0, bg: isToggled ? "accent" : "icon" }}
/>
)}
</Flex>
);
}
export default Toggle;

View File

@@ -437,6 +437,17 @@ const features = {
legacyPro: createLimit(true)
}
}),
monographAnalytics: createFeature({
id: "monographAnalytics",
title: "Monographs analytics",
availability: {
free: createLimit(false),
essential: createLimit(false),
pro: createLimit(true),
believer: createLimit(true),
legacyPro: createLimit(true)
}
}),
sms2FA: createFeature({
id: "sms2FA",
title: "2FA via SMS",

View File

@@ -37,6 +37,9 @@ type EncryptedMonograph = MonographApiRequestBase & {
type MonographApiRequest = (UnencryptedMonograph | EncryptedMonograph) & {
userId: string;
};
export type MonographAnalytics = {
totalViews: number;
};
export type PublishOptions = { password?: string; selfDestruct?: boolean };
export class Monographs {
@@ -187,4 +190,17 @@ export class Monographs {
if (!monographPasswordsKey) return "";
return this.db.storage().decrypt(monographPasswordsKey, password);
}
async analytics(monographId: string): Promise<MonographAnalytics> {
try {
const token = await this.db.tokenManager.getAccessToken();
const analytics = (await http.get(
`${Constants.API_HOST}/monographs/${monographId}/analytics`,
token
)) as MonographAnalytics;
return analytics;
} catch {
return { totalViews: 0 };
}
}
}

View File

@@ -1600,6 +1600,10 @@ msgstr "Click to remove"
msgid "Click to reset {title}"
msgstr "Click to reset {title}"
#: src/strings.ts:2610
msgid "Click to update"
msgstr "Click to update"
#: src/strings.ts:1380
msgid "Close"
msgstr "Close"
@@ -4100,6 +4104,10 @@ msgstr "No notebooks selected to move"
msgid "No one can view this {type} except you."
msgstr "No one can view this {type} except you."
#: src/strings.ts:2611
msgid "No password"
msgstr "No password"
#: src/strings.ts:279
msgid "No references found of this note"
msgstr "No references found of this note"
@@ -4870,6 +4878,10 @@ msgstr "Publish"
msgid "Publish note"
msgstr "Publish note"
#: src/strings.ts:2612
msgid "Publish to the web"
msgstr "Publish to the web"
#: src/strings.ts:488
msgid "Publish your note to share it with others. You can set a password to protect it."
msgstr "Publish your note to share it with others. You can set a password to protect it."
@@ -7030,6 +7042,10 @@ msgstr "View source code"
msgid "View your recovery codes to recover your account in case you lose access to your two-factor authentication methods."
msgstr "View your recovery codes to recover your account in case you lose access to your two-factor authentication methods."
#: src/strings.ts:2609
msgid "Views"
msgstr "Views"
#: src/strings.ts:416
msgid "Visit homepage"
msgstr "Visit homepage"

View File

@@ -1589,6 +1589,10 @@ msgstr ""
msgid "Click to reset {title}"
msgstr ""
#: src/strings.ts:2610
msgid "Click to update"
msgstr ""
#: src/strings.ts:1380
msgid "Close"
msgstr ""
@@ -4080,6 +4084,10 @@ msgstr ""
msgid "No one can view this {type} except you."
msgstr ""
#: src/strings.ts:2611
msgid "No password"
msgstr ""
#: src/strings.ts:279
msgid "No references found of this note"
msgstr ""
@@ -4844,6 +4852,10 @@ msgstr ""
msgid "Publish note"
msgstr ""
#: src/strings.ts:2612
msgid "Publish to the web"
msgstr ""
#: src/strings.ts:488
msgid "Publish your note to share it with others. You can set a password to protect it."
msgstr ""
@@ -6981,6 +6993,10 @@ msgstr ""
msgid "View your recovery codes to recover your account in case you lose access to your two-factor authentication methods."
msgstr ""
#: src/strings.ts:2609
msgid "Views"
msgstr ""
#: src/strings.ts:416
msgid "Visit homepage"
msgstr ""

View File

@@ -2605,5 +2605,9 @@ Use this if changes from other devices are not appearing on this device. This wi
clickToDirectlyClaimPromo: () =>
t`Click here to directly claim the promotion.`,
loginToUploadAttachments: () =>
t`Login to upload attachments. [Read more](https://help.notesnook.com/faqs/login-to-upload-attachments)`
t`Login to upload attachments. [Read more](https://help.notesnook.com/faqs/login-to-upload-attachments)`,
views: () => t`Views`,
clickToUpdate: () => t`Click to update`,
noPassword: () => t`No password`,
publishToTheWeb: () => t`Publish to the web`
};