diff --git a/apps/web/src/components/editor/action-bar.tsx b/apps/web/src/components/editor/action-bar.tsx index 56a247d8d..ed6f98965 100644 --- a/apps/web/src/components/editor/action-bar.tsx +++ b/apps/web/src/components/editor/action-bar.tsx @@ -91,7 +91,7 @@ type ToolButton = { hidden?: boolean; hideOnMobile?: boolean; toggled?: boolean; - onClick: () => void; + onClick: (e: React.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(), diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index 851872601..5eb5db14b 100644 --- a/apps/web/src/components/note/index.tsx +++ b/apps/web/src/components/note/index.tsx @@ -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", diff --git a/apps/web/src/components/publish-view/index.tsx b/apps/web/src/components/publish-view/index.tsx index f233aca35..6e878c176 100644 --- a/apps/web/src/components/publish-view/index.tsx +++ b/apps/web/src/components/publish-view/index.tsx @@ -18,34 +18,39 @@ along with this program. If not, see . */ 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( - 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(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 ( - - - + {monograph?.id ? ( + - {note.title} - - {isPublishing ? ( - - {strings.pleaseWait()}... - {processingStatus && ( - - {strings.downloadingImages()} ({processingStatus.current}/ - {processingStatus.total}) - - )} + {`${hosts.MONOGRAPH_HOST}/${monograph?.id}`} + + + + ) : null} + + {monograph?.publishedAt ? ( + + {strings.publishedAt()} + + {getFormattedDate(monograph?.publishedAt, "date-time")} + - ) : ( - <> - {publishId ? ( - - - {strings.publishedAt()} - - - + {strings.views()} + {monographAnalytics?.isAllowed ? ( + analytics.status === "fulfilled" ? ( + + - {`${hosts.MONOGRAPH_HOST}/${publishId}`} - + {analytics.value.totalViews} + - - ) : ( - + ) + ) : monographAnalytics ? ( + + ) : null} + + ) : null} + setSelfDestruct((s) => !s)} + title={strings.monographSelfDestructDesc()} + > + {strings.monographSelfDestructHeading()} + e.stopPropagation()} + /> + + + + {strings.monographPassHeading()} + + - { - 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 && ( - { 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" ? ( + + ) : ( + strings.unpublish() + )} + )} + - + {processingStatus ? ( + + {strings.downloadingImages()} {processingStatus.current}/ + {processingStatus.total || "?"} + + ) : 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( - 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" - } - }} - > - - , - 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( + close()} + position={{ + target, + location: "below", + isTargetAbsolute: true, + yOffset: 10, + xOffset: -10 + }} + sx={{ + boxShadow: "0px 0px 15px 0px #00000011" + }} + scope="dialog" + > + + {strings.publishToTheWeb()} + {strings.monographDesc()} + close()} + /> + + + ); +} + +export const PublishDialog = DialogManager.register(function PublishDialog( + props: BaseDialogProps & { note: Note } +) { + const monograph = usePromise( + () => resolveMonograph(props.note.id), + [props.note.id] + ); + + if (monograph.status !== "fulfilled") return null; + return ( + props.onClose(false)} + > + + + + + ); +}); + +type ResolvedMonograph = { + id: string; + selfDestruct: boolean; + publishedAt?: number; + password?: string; +}; + +async function resolveMonograph( + monographId: string +): Promise { + 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 + }; } diff --git a/apps/web/src/components/toggle/index.jsx b/apps/web/src/components/toggle/index.jsx deleted file mode 100644 index d46134ae9..000000000 --- a/apps/web/src/components/toggle/index.jsx +++ /dev/null @@ -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 . -*/ - -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 ( - label": { width: "auto", flexShrink: 0 } - }} - > - - {isLoading ? ( - - ) : ( - - )} - - ); -} -export default Toggle; diff --git a/packages/common/src/utils/is-feature-available.ts b/packages/common/src/utils/is-feature-available.ts index fcf70c953..27e0cd6f0 100644 --- a/packages/common/src/utils/is-feature-available.ts +++ b/packages/common/src/utils/is-feature-available.ts @@ -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", diff --git a/packages/core/src/api/monographs.ts b/packages/core/src/api/monographs.ts index f0f30dd21..53e11cc10 100644 --- a/packages/core/src/api/monographs.ts +++ b/packages/core/src/api/monographs.ts @@ -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 { + 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 }; + } + } } diff --git a/packages/intl/locale/en.po b/packages/intl/locale/en.po index 43a2b8f5d..9cab86210 100644 --- a/packages/intl/locale/en.po +++ b/packages/intl/locale/en.po @@ -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" diff --git a/packages/intl/locale/pseudo-LOCALE.po b/packages/intl/locale/pseudo-LOCALE.po index 2b257cbea..4b597f800 100644 --- a/packages/intl/locale/pseudo-LOCALE.po +++ b/packages/intl/locale/pseudo-LOCALE.po @@ -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 "" diff --git a/packages/intl/src/strings.ts b/packages/intl/src/strings.ts index f9f9e0223..f71e13b4d 100644 --- a/packages/intl/src/strings.ts +++ b/packages/intl/src/strings.ts @@ -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` };