diff --git a/apps/web/src/app-effects.tsx b/apps/web/src/app-effects.tsx index aa6e30b30..49c5c736b 100644 --- a/apps/web/src/app-effects.tsx +++ b/apps/web/src/app-effects.tsx @@ -31,22 +31,18 @@ import { import { introduceFeatures, showUpgradeReminderDialogs } from "./common"; import { AppEventManager, AppEvents } from "./common/app-events"; import { db } from "./common/db"; -import { CHECK_IDS, EV, EVENTS } from "@notesnook/core"; +import { EV, EVENTS } from "@notesnook/core"; import { registerKeyMap } from "./common/key-map"; -import { isUserPremium } from "./hooks/use-is-user-premium"; import { updateStatus, removeStatus, getStatus } from "./hooks/use-status"; -import { showToast } from "./utils/toast"; import { interruptedOnboarding, OnboardingDialog } from "./dialogs/onboarding-dialog"; import { hashNavigate } from "./navigation"; import { desktop } from "./common/desktop-bridge"; -import { BuyDialog } from "./dialogs/buy-dialog"; import { FeatureDialog } from "./dialogs/feature-dialog"; import { AnnouncementDialog } from "./dialogs/announcement-dialog"; import { logger } from "./utils/logger"; -import { strings } from "@notesnook/intl"; export default function AppEffects() { const refreshNavItems = useStore((store) => store.refreshNavItems); @@ -63,38 +59,6 @@ export default function AppEffects() { useEffect( function initializeApp() { - const userCheckStatusEvent = EV.subscribe( - EVENTS.userCheckStatus, - async (type: string) => { - if (isUserPremium()) { - return { type, result: true }; - } else { - let sentence; - switch (type) { - case CHECK_IDS.noteColor: - sentence = strings.upgradeToProToUseFeature("color"); - break; - case CHECK_IDS.noteTag: - sentence = strings.upgradeToProToUseFeature("tags"); - break; - case CHECK_IDS.notebookAdd: - sentence = strings.upgradeToProToUseFeature("notebook"); - break; - case CHECK_IDS.vaultAdd: - sentence = strings.upgradeToProToUseFeature("vault"); - break; - default: - sentence = strings.upgradeToProToUseFeature(); - break; - } - showToast("error", sentence, [ - { text: strings.upgradeNow(), onClick: () => BuyDialog.show({}) } - ]); - return { type, result: false }; - } - } - ); - initStore(); initEditorStore(); @@ -116,10 +80,6 @@ export default function AppEffects() { // NOTE: we deliberately don't await here because we don't want to pause execution. db.attachments.cacheAttachments().catch(logger.error); })(); - - return () => { - userCheckStatusEvent.unsubscribe(); - }; }, [ initEditorStore, diff --git a/apps/web/src/common/notices.ts b/apps/web/src/common/notices.ts index ee0fcf63b..b58fcd3a6 100644 --- a/apps/web/src/common/notices.ts +++ b/apps/web/src/common/notices.ts @@ -24,7 +24,6 @@ import { store as appStore } from "../stores/app-store"; import { Backup, User, Email, Warn, Icon } from "../components/icons"; import dayjs from "dayjs"; import { hardNavigate, hashNavigate } from "../navigation"; -import { isUserPremium } from "../hooks/use-is-user-premium"; import { showToast } from "../utils/toast"; import { TaskScheduler } from "../utils/task-scheduler"; import { BuyDialog } from "../dialogs/buy-dialog"; @@ -91,12 +90,7 @@ export async function shouldAddAutoBackupsDisabledNotice() { if (!user) return false; const backupInterval = Config.get("backupReminderOffset", 0); - if (!isUserPremium(user) && backupInterval) { - Config.set("backupReminderOffset", 0); - return true; - } - - return false; + return backupInterval === 0; } export async function shouldAddBackupNotice() { @@ -206,7 +200,7 @@ let openedToast: { hide: () => void } | null = null; async function saveBackup(mode: "full" | "partial" = "partial") { if (IS_DESKTOP_APP) { await createBackup({ noVerify: true, mode, background: true }); - } else if (isUserPremium() && !IS_TESTING) { + } else if (!IS_TESTING) { if (openedToast !== null) return; openedToast = showToast( "success", diff --git a/apps/web/src/components/editor/header.tsx b/apps/web/src/components/editor/header.tsx index 1238e3228..9cbbbb4ba 100644 --- a/apps/web/src/components/editor/header.tsx +++ b/apps/web/src/components/editor/header.tsx @@ -32,6 +32,8 @@ import { MenuItem } from "@notesnook/ui"; import { navigate } from "../../navigation"; import { Tag } from "@notesnook/core"; import { strings } from "@notesnook/intl"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../../common/toasts"; type HeaderProps = { readonly: boolean; id: string }; function Header(props: HeaderProps) { @@ -51,9 +53,12 @@ function Header(props: HeaderProps) { if (oldTag) { await db.relations.unlink(oldTag, { type: "note", id: noteId }); } else { - const id = - (await db.tags.find(value))?.id ?? - (await db.tags.add({ title: value })); + let id = (await db.tags.find(value))?.id; + if (!id) { + const result = await isFeatureAvailable("tags"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + id = await db.tags.add({ title: value }); + } if (!id) return; await db.relations.add({ id, type: "tag" }, { type: "note", id: noteId }); await useTagStore.getState().refresh(); diff --git a/apps/web/src/components/editor/picker.ts b/apps/web/src/components/editor/picker.ts index c68a6df1f..18ebb60c8 100644 --- a/apps/web/src/components/editor/picker.ts +++ b/apps/web/src/components/editor/picker.ts @@ -21,12 +21,10 @@ import { SerializedKey } from "@notesnook/crypto"; import { AppEventManager, AppEvents } from "../../common/app-events"; import { db } from "../../common/db"; import { TaskManager } from "../../common/task-manager"; -import { isUserPremium } from "../../hooks/use-is-user-premium"; import { showToast } from "../../utils/toast"; import { showFilePicker } from "../../utils/file-picker"; import { Attachment } from "@notesnook/editor"; import { ImagePickerDialog } from "../../dialogs/image-picker-dialog"; -import { BuyDialog } from "../../dialogs/buy-dialog"; import { strings } from "@notesnook/intl"; import { getUploadedFileSize, @@ -36,16 +34,9 @@ import { import Config from "../../utils/config"; import { compressImage, FileWithURI } from "../../utils/image-compressor"; import { ImageCompressionOptions } from "../../stores/setting-store"; - -const FILE_SIZE_LIMIT = 500 * 1024 * 1024; -const IMAGE_SIZE_LIMIT = 50 * 1024 * 1024; +import { isFeatureAvailable } from "@notesnook/common"; export async function insertAttachments(type = "*/*") { - if (!isUserPremium()) { - await BuyDialog.show({}); - return; - } - const files = await showFilePicker({ acceptedFileTypes: type || "*/*", multiple: true @@ -55,11 +46,6 @@ export async function insertAttachments(type = "*/*") { } export async function attachFiles(files: File[]) { - if (!isUserPremium()) { - await BuyDialog.show({}); - return; - } - let images = files.filter((f) => f.type.startsWith("image/")); const imageCompressionConfig = Config.get( "imageCompression", @@ -68,7 +54,7 @@ export async function attachFiles(files: File[]) { switch (imageCompressionConfig) { case ImageCompressionOptions.ENABLE: { - let compressedImages: FileWithURI[] = []; + const compressedImages: FileWithURI[] = []; for (const image of images) { const compressed = await compressImage(image, { maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7), @@ -143,8 +129,8 @@ async function pickFile( options?: AddAttachmentOptions ): Promise { try { - if (file.size > FILE_SIZE_LIMIT) - throw new Error(strings.fileTooLargeDesc(500)); + const feature = await isFeatureAvailable("fileSize", file.size); + if (!feature.isAllowed) throw new Error(feature.error); const hash = await addAttachment(file, options); return { @@ -169,9 +155,8 @@ async function pickImage( options?: AddAttachmentOptions ): Promise { try { - if (file.size > IMAGE_SIZE_LIMIT) - throw new Error(strings.imageTooLarge(50)); - if (!file) return; + const feature = await isFeatureAvailable("fileSize", file.size); + if (!feature.isAllowed) throw new Error(feature.error); const hash = await addAttachment(file, options); const dimensions = await getImageDimensions(file); diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx index 035363db9..46262661e 100644 --- a/apps/web/src/components/editor/tiptap.tsx +++ b/apps/web/src/components/editor/tiptap.tsx @@ -50,9 +50,12 @@ import { } from "react"; import { IEditor, MAX_AUTO_SAVEABLE_WORDS } from "./types"; import { useEditorConfig, useToolbarConfig, useEditorManager } from "./manager"; -import { useIsUserPremium } from "../../hooks/use-is-user-premium"; import { useStore as useSettingsStore } from "../../stores/setting-store"; -import { debounce } from "@notesnook/common"; +import { + debounce, + useAreFeaturesAvailable, + useIsFeatureAvailable +} from "@notesnook/common"; import { ScopedThemeProvider } from "../theme-provider"; import { useStore as useThemeStore } from "../../stores/theme-store"; import { writeToClipboard } from "../../utils/clipboard"; @@ -65,6 +68,7 @@ import { TimeFormat } from "@notesnook/core"; import { BuyDialog } from "../../dialogs/buy-dialog"; import { EDITOR_ZOOM } from "./common"; import { ScrollContainer } from "@notesnook/ui"; +import { showFeatureNotAllowedToast } from "../../common/toasts"; export type OnChangeHandler = ( content: () => string, @@ -183,16 +187,31 @@ function TipTap(props: TipTapProps) { fontLigatures } = props; - const isUserPremium = useIsUserPremium(); const autoSave = useRef(true); const { toolbarConfig } = useToolbarConfig(); + const features = useAreFeaturesAvailable([ + "callout", + "outlineList", + "taskList" + ]); + usePermissionHandler({ claims: { - premium: isUserPremium + callout: !!features?.callout?.isAllowed, + outlineList: !!features?.outlineList?.isAllowed, + taskList: !!features?.taskList?.isAllowed }, - onPermissionDenied: (claim) => { - if (claim === "premium") BuyDialog.show({}); + onPermissionDenied: (claim, silent) => { + if (silent) { + console.log(features, features?.[claim]); + if (features?.[claim]) showFeatureNotAllowedToast(features[claim]); + return; + } + + BuyDialog.show({ + plan: features?.[claim]?.availableOn + }); } }); diff --git a/apps/web/src/components/navigation-menu/index.tsx b/apps/web/src/components/navigation-menu/index.tsx index 59dfbef8e..d40ef3368 100644 --- a/apps/web/src/components/navigation-menu/index.tsx +++ b/apps/web/src/components/navigation-menu/index.tsx @@ -107,6 +107,8 @@ import { useStore as useNotebookStore } from "../../stores/notebook-store"; import { useStore as useTagStore } from "../../stores/tag-store"; import { showSortMenu } from "../group-header"; import { BuyDialog } from "../../dialogs/buy-dialog"; +import { useIsFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../../common/toasts"; type Route = { id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive"; @@ -902,6 +904,7 @@ function ReorderableList( const [activeItem, setActiveItem] = useState(); const [order, setOrder] = usePersistentState(orderKey, _order()); const orderedItems = orderItems(items, order); + const customizableSidebar = useIsFeatureAvailable("customizableSidebar"); useEffect(() => { setOrder(_order()); @@ -912,10 +915,10 @@ function ReorderableList( sensors={sensors} collisionDetection={closestCenter} cancelDrop={() => { - // if (!isUserPremium()) { - // showToast("error", "You need to be Pro to customize the sidebar."); - // return true; - // } + if (!customizableSidebar?.isAllowed) { + showFeatureNotAllowedToast(customizableSidebar); + return true; + } return false; }} onDragStart={(event) => { diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index a65d08c74..851872601 100644 --- a/apps/web/src/components/note/index.tsx +++ b/apps/web/src/components/note/index.tsx @@ -47,7 +47,6 @@ import { AddTagsDialog } from "../../dialogs/add-tags-dialog"; import { ConfirmDialog } from "../../dialogs/confirm"; import { CreateColorDialog } from "../../dialogs/create-color-dialog"; import { MoveNoteDialog } from "../../dialogs/move-note-dialog"; -import { isUserPremium } from "../../hooks/use-is-user-premium"; import { navigate } from "../../navigation"; import { useEditorStore } from "../../stores/editor-store"; import { useStore as useMonographStore } from "../../stores/monograph-store"; @@ -332,7 +331,6 @@ export const noteMenuItems: ( ids?: string[], context?: { color?: Color; locked?: boolean } ) => MenuItem[] = (note, ids = [], context) => { - const isPro = isUserPremium(); // const isSynced = db.notes.note(note.id)?.synced(); return [ @@ -377,7 +375,6 @@ export const noteMenuItems: ( //isDisabled: !isSynced, title: strings.lock(), isChecked: context?.locked, - isDisabled: !isPro, icon: Lock.path, onClick: async () => { const { unlock, lock } = store.get(); @@ -517,11 +514,7 @@ export const noteMenuItems: ( title: format.title, tooltip: strings.exportAs(format.title), icon: format.icon.path, - isDisabled: - (format.type !== "txt" && !isPro) || - (format.type === "pdf" && ids.length > 1), - // ? "Multiple notes cannot be exported as PDF." - // : false, + isDisabled: format.type === "pdf" && ids.length > 1, multiSelect: true, onClick: async () => { if (ids.length === 1) { diff --git a/apps/web/src/components/notebook/index.tsx b/apps/web/src/components/notebook/index.tsx index 86a7ff6ac..5864f2a30 100644 --- a/apps/web/src/components/notebook/index.tsx +++ b/apps/web/src/components/notebook/index.tsx @@ -47,6 +47,8 @@ import { db } from "../../common/db"; import { createSetDefaultHomepageMenuItem } from "../../common"; import { useStore as useNotebookStore } from "../../stores/notebook-store"; import { MoveNotebookDialog } from "../../dialogs/move-notebook-dialog"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../../common/toasts"; type NotebookProps = { item: NotebookType; @@ -210,6 +212,10 @@ export const notebookMenuItems: ( onClick: async () => { const defaultNotebook = db.settings.getDefaultNotebook(); const isDefault = defaultNotebook === notebook.id; + if (!isDefault) { + const result = await isFeatureAvailable("defaultNotebookAndTag"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + } await db.settings.setDefaultNotebook( isDefault ? undefined : notebook.id ); diff --git a/apps/web/src/components/tag/index.tsx b/apps/web/src/components/tag/index.tsx index 8bbdd6aae..688d8c853 100644 --- a/apps/web/src/components/tag/index.tsx +++ b/apps/web/src/components/tag/index.tsx @@ -32,6 +32,8 @@ import { useStore as useNoteStore } from "../../stores/note-store"; import { Multiselect } from "../../common/multi-select"; import { strings } from "@notesnook/intl"; import { createSetDefaultHomepageMenuItem } from "../../common"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../../common/toasts"; type TagProps = { item: TagType; totalNotes: number }; function Tag(props: TagProps) { @@ -123,6 +125,10 @@ export const tagMenuItems: (tag: TagType, ids?: string[]) => MenuItem[] = ( onClick: async () => { const defaultTag = db.settings.getDefaultTag(); const isDefault = defaultTag === tag.id; + if (!isDefault) { + const result = await isFeatureAvailable("defaultNotebookAndTag"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + } await db.settings.setDefaultTag(isDefault ? undefined : tag.id); } }, diff --git a/apps/web/src/components/toggle/index.jsx b/apps/web/src/components/toggle/index.jsx index 280853a04..df65616ac 100644 --- a/apps/web/src/components/toggle/index.jsx +++ b/apps/web/src/components/toggle/index.jsx @@ -19,10 +19,8 @@ along with this program. If not, see . import { useCallback, useState } from "react"; import Tip from "../tip"; -import { isUserPremium } from "../../hooks/use-is-user-premium"; import { Flex, Switch } from "@theme-ui/components"; import { Loading } from "../icons"; -import { BuyDialog } from "../../dialogs/buy-dialog/buy-dialog"; function Toggle(props) { const { @@ -32,24 +30,21 @@ function Toggle(props) { isToggled, onToggled, onlyIf, - premium, testId, disabled, tip } = props; const [isLoading, setIsLoading] = useState(false); const onClick = useCallback(async () => { - if (isUserPremium() || !premium || isToggled) { + if (isToggled) { setIsLoading(true); try { await onToggled(); } finally { setIsLoading(false); } - } else { - await BuyDialog.show({}); } - }, [onToggled, premium, isToggled]); + }, [onToggled, isToggled]); if (onlyIf === false) return null; return ( diff --git a/apps/web/src/dialogs/add-notebook-dialog.tsx b/apps/web/src/dialogs/add-notebook-dialog.tsx index bc9a6e337..11da96bf8 100644 --- a/apps/web/src/dialogs/add-notebook-dialog.tsx +++ b/apps/web/src/dialogs/add-notebook-dialog.tsx @@ -28,6 +28,8 @@ import { store as appStore } from "../stores/app-store"; import { db } from "../common/db"; import { BaseDialogProps, DialogManager } from "../common/dialog-manager"; import { strings } from "@notesnook/intl"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../common/toasts"; type AddNotebookDialogProps = BaseDialogProps & { parentId?: string; @@ -45,6 +47,11 @@ export const AddNotebookDialog = DialogManager.register( if (!title.current.trim()) return showToast("error", strings.allFieldsRequired()); + const result = await isFeatureAvailable("notebooks"); + if (!result.isAllowed) { + return showFeatureNotAllowedToast(result); + } + const id = await db.notebooks.add({ id: props.notebook?.id, title: title.current, diff --git a/apps/web/src/dialogs/add-reminder-dialog.tsx b/apps/web/src/dialogs/add-reminder-dialog.tsx index 84cac7494..f64881766 100644 --- a/apps/web/src/dialogs/add-reminder-dialog.tsx +++ b/apps/web/src/dialogs/add-reminder-dialog.tsx @@ -26,17 +26,21 @@ import { useRef, useState } from "react"; import { db } from "../common/db"; import { useStore } from "../stores/reminder-store"; import { showToast } from "../utils/toast"; -import { useIsUserPremium } from "../hooks/use-is-user-premium"; import { Calendar, Pro } from "../components/icons"; import { usePersistentState } from "../hooks/use-persistent-state"; import { DayPicker } from "../components/day-picker"; import { PopupPresenter } from "@notesnook/ui"; import { useStore as useThemeStore } from "../stores/theme-store"; -import { getFormattedDate } from "@notesnook/common"; +import { + isFeatureAvailable, + getFormattedDate, + useIsFeatureAvailable +} from "@notesnook/common"; import { MONTHS_FULL, getTimeFormat } from "@notesnook/core"; import { Note, Reminder } from "@notesnook/core"; import { BaseDialogProps, DialogManager } from "../common/dialog-manager"; import { strings } from "@notesnook/intl"; +import { showFeatureNotAllowedToast } from "../common/toasts"; dayjs.extend(customParseFormat); @@ -74,8 +78,7 @@ const modes = [ }, { id: Modes.REPEAT, - title: "Repeat", - premium: true + title: "Repeat" } ]; const priorities = [ @@ -140,9 +143,9 @@ export const AddReminderDialog = DialogManager.register( ); const [showCalendar, setShowCalendar] = useState(false); const refresh = useStore((state) => state.refresh); - const isUserPremium = useIsUserPremium(); const theme = useThemeStore((store) => store.colorScheme); const dateInputRef = useRef(null); + const repeatModeAvailability = useIsFeatureAvailable("recurringReminders"); const repeatsDaily = (selectedDays.length === 7 && recurringMode === RecurringModes.WEEK) || @@ -180,6 +183,12 @@ export const AddReminderDialog = DialogManager.register( return; } + const feature = await isFeatureAvailable("activeReminders"); + if (!feature.isAllowed) { + showFeatureNotAllowedToast(feature); + return; + } + const id = await db.reminders.add({ id: reminder?.id, recurringMode, @@ -249,17 +258,18 @@ export const AddReminderDialog = DialogManager.register( name="mode" defaultChecked={m.id === Modes.ONCE} checked={m.id === mode} - disabled={m.premium && !isUserPremium} + disabled={ + m.id === "repeat" && !repeatModeAvailability?.isAllowed + } sx={{ color: m.id === mode ? "accent" : "icon" }} - onChange={() => { - if (m.premium && !isUserPremium) return; + onChange={async () => { setMode(m.id); setRecurringMode(RecurringModes.DAY); setSelectedDays([]); }} /> {strings.reminderModes(m.id)} - {m.premium && !isUserPremium && ( + {m.id === "repeat" && !repeatModeAvailability?.isAllowed && ( )} diff --git a/apps/web/src/dialogs/add-tags-dialog.tsx b/apps/web/src/dialogs/add-tags-dialog.tsx index 0bd257ca5..f07cb3a88 100644 --- a/apps/web/src/dialogs/add-tags-dialog.tsx +++ b/apps/web/src/dialogs/add-tags-dialog.tsx @@ -26,7 +26,7 @@ import { store as notestore } from "../stores/note-store"; import { FilteredList } from "../components/filtered-list"; import { ItemReference, Tag } from "@notesnook/core"; import { VirtualizedGrouping } from "@notesnook/core"; -import { ResolvedItem } from "@notesnook/common"; +import { isFeatureAvailable, ResolvedItem } from "@notesnook/common"; import { BaseDialogProps, DialogManager } from "../common/dialog-manager"; import { strings } from "@notesnook/intl"; import { @@ -35,6 +35,7 @@ import { selectMultiple, useSelectionStore } from "./move-note-dialog"; +import { showFeatureNotAllowedToast } from "../common/toasts"; type AddTagsDialogProps = BaseDialogProps & { noteIds: string[] }; export const AddTagsDialog = DialogManager.register(function AddTagsDialog( @@ -115,6 +116,11 @@ export const AddTagsDialog = DialogManager.register(function AddTagsDialog( ); }} onCreateNewItem={async (title) => { + const result = await isFeatureAvailable("tags"); + if (!result.isAllowed) { + return showFeatureNotAllowedToast(result); + } + const tagId = await db.tags.add({ title }); if (!tagId) return; await useStore.getState().refresh(); diff --git a/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx b/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx index 369815f2c..a0d4e1884 100644 --- a/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx +++ b/apps/web/src/dialogs/buy-dialog/buy-dialog.tsx @@ -29,7 +29,7 @@ import Field from "../../components/field"; import { hardNavigate } from "../../navigation"; import { Features } from "./features"; import { PaddleCheckout } from "./paddle"; -import { Period, Plan, PlanId, Price, PricingInfo } from "./types"; +import { Period, Plan, Price, PricingInfo } from "./types"; import { usePlans } from "./plans"; import { formatRecurringPeriodShort, @@ -46,13 +46,13 @@ import { isUserSubscribed } from "../../hooks/use-is-user-premium"; import { SUBSCRIPTION_STATUS } from "../../common/constants"; import BaseDialog from "../../components/dialog"; import { ScopedThemeProvider } from "../../components/theme-provider"; -import { User } from "@notesnook/core"; +import { SubscriptionPlan, User } from "@notesnook/core"; import { BaseDialogProps, DialogManager } from "../../common/dialog-manager"; import { strings } from "@notesnook/intl"; type BuyDialogProps = BaseDialogProps & { couponCode?: string; - plan?: PlanId; + plan?: SubscriptionPlan; onClose: () => void; }; @@ -115,7 +115,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog( > onClose(false)} - initialPlan={plan || "free"} + initialPlan={plan || SubscriptionPlan.FREE} user={user} /> @@ -126,7 +126,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog( }); type SideBarProps = { - initialPlan: PlanId; + initialPlan: SubscriptionPlan; onClose: () => void; user?: User; }; @@ -171,7 +171,7 @@ export function CheckoutSideBar(props: SideBarProps) { if (user) return ( { // if (!initialPlan || showPlans) return; // const plan = plans.find((p) => p.id === initialPlan); @@ -476,7 +476,7 @@ function SelectedPlan(props: SelectedPlanProps) { > {plan.title} - {plan.id === "education" && ( + {plan.id === SubscriptionPlan.EDUCATION && ( . import { Text, Flex, Button, Image } from "@theme-ui/components"; import { Loading } from "../../components/icons"; import Nomad from "../../assets/nomad.svg?url"; -import { Period, Plan, PlanId, Price } from "./types"; +import { Period, Plan, Price } from "./types"; import { usePlans } from "./plans"; import { useEffect, useState } from "react"; import { getCurrencySymbol, parseAmount } from "./helpers"; import { strings } from "@notesnook/intl"; +import { SubscriptionPlan } from "@notesnook/core"; type PlansListProps = { - selectedPlan: PlanId; + selectedPlan: SubscriptionPlan; onPlanSelected: (plan: Plan, price: Price) => void; onPlansLoaded?: (plans: Plan[]) => void; }; diff --git a/apps/web/src/dialogs/buy-dialog/plans.ts b/apps/web/src/dialogs/buy-dialog/plans.ts index d4135eac8..87688d7ed 100644 --- a/apps/web/src/dialogs/buy-dialog/plans.ts +++ b/apps/web/src/dialogs/buy-dialog/plans.ts @@ -20,20 +20,21 @@ along with this program. If not, see . import { useEffect, useState } from "react"; import { Period, Plan, Price } from "./types"; import { IS_DEV } from "./helpers"; +import { SubscriptionPlan } from "@notesnook/core"; function createPrice(id: string, period: Period, subtotal: number): Price { return { id, period, - subtotal, - total: 0, - tax: 0, + subtotal: `${subtotal}USD`, + total: `0.00USD`, + tax: `0.00USD`, currency: "USD" }; } const FREE_PLAN: Plan = { - id: "free", + id: SubscriptionPlan.FREE, title: "Free", recurring: true, prices: [ @@ -46,7 +47,7 @@ const FREE_PLAN: Plan = { export const DEFAULT_PLANS: Plan[] = [ FREE_PLAN, { - id: "essential", + id: SubscriptionPlan.ESSENTIAL, title: "Essential", recurring: true, prices: [ @@ -67,7 +68,7 @@ export const DEFAULT_PLANS: Plan[] = [ ] }, { - id: "pro", + id: SubscriptionPlan.PRO, title: "Pro", recurring: true, prices: [ @@ -95,7 +96,7 @@ export const DEFAULT_PLANS: Plan[] = [ ] }, { - id: "believer", + id: SubscriptionPlan.BELIEVER, title: "Believer", recurring: true, prices: [ @@ -123,7 +124,7 @@ export const DEFAULT_PLANS: Plan[] = [ ] }, { - id: "education", + id: SubscriptionPlan.EDUCATION, title: "Education", recurring: false, prices: [ diff --git a/apps/web/src/dialogs/buy-dialog/types.ts b/apps/web/src/dialogs/buy-dialog/types.ts index 31789f7bb..dfb62a1c1 100644 --- a/apps/web/src/dialogs/buy-dialog/types.ts +++ b/apps/web/src/dialogs/buy-dialog/types.ts @@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +import { SubscriptionPlan } from "@notesnook/core"; import { CheckoutEventNames, CheckoutEventsCustomer, @@ -51,10 +52,9 @@ export type PaddleEvent = { callback_data: PaddleEventData; }; -export type PlanId = "free" | "essential" | "pro" | "believer" | "education"; export interface Plan { // period: Period; - id: PlanId; + id: SubscriptionPlan; title: string; prices: Price[]; recurring: boolean; diff --git a/apps/web/src/dialogs/create-color-dialog.tsx b/apps/web/src/dialogs/create-color-dialog.tsx index 146307248..623eb355e 100644 --- a/apps/web/src/dialogs/create-color-dialog.tsx +++ b/apps/web/src/dialogs/create-color-dialog.tsx @@ -25,6 +25,8 @@ import { db } from "../common/db"; import { showToast } from "../utils/toast"; import { BaseDialogProps, DialogManager } from "../common/dialog-manager"; import { strings } from "@notesnook/intl"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../common/toasts"; type CreateColorDialogProps = BaseDialogProps; export const CreateColorDialog = DialogManager.register( @@ -59,6 +61,10 @@ export const CreateColorDialog = DialogManager.register( showToast("error", strings.invalidHexColor()); return; } + + const result = await isFeatureAvailable("colors"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + const colorId = await db.colors.add({ colorCode: form.color, title: form.title diff --git a/apps/web/src/dialogs/image-picker-dialog.tsx b/apps/web/src/dialogs/image-picker-dialog.tsx index 25b2e8a79..17fbf7c18 100644 --- a/apps/web/src/dialogs/image-picker-dialog.tsx +++ b/apps/web/src/dialogs/image-picker-dialog.tsx @@ -21,10 +21,11 @@ import { useEffect, useState } from "react"; import Dialog from "../components/dialog"; import { ScrollContainer } from "@notesnook/ui"; import { Flex, Image, Label, Text } from "@theme-ui/components"; -import { formatBytes } from "@notesnook/common"; +import { formatBytes, isFeatureAvailable } from "@notesnook/common"; import { compressImage, FileWithURI } from "../utils/image-compressor"; import { BaseDialogProps, DialogManager } from "../common/dialog-manager"; import { strings } from "@notesnook/intl"; +import { showFeatureNotAllowedToast } from "../common/toasts"; export type ImagePickerDialogProps = BaseDialogProps & { images: File[]; @@ -130,7 +131,11 @@ export const ImagePickerDialog = DialogManager.register( }} defaultChecked={compress} checked={compress} - onChange={() => setCompress((s) => !s)} + onChange={async () => { + const result = await isFeatureAvailable("fullQualityImages"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + setCompress((s) => !s); + }} /> Enable compression (recommended) diff --git a/apps/web/src/dialogs/issue-dialog.tsx b/apps/web/src/dialogs/issue-dialog.tsx index 9827bb2a4..79bd38437 100644 --- a/apps/web/src/dialogs/issue-dialog.tsx +++ b/apps/web/src/dialogs/issue-dialog.tsx @@ -30,7 +30,6 @@ import { ConfirmDialog } from "./confirm"; import { BaseDialogProps, DialogManager } from "../common/dialog-manager"; import { strings } from "@notesnook/intl"; import { getDeviceInfo } from "../utils/platform"; -import { isUserPremium } from "../hooks/use-is-user-premium"; const PLACEHOLDERS = { title: strings.issueTitlePlaceholder(), diff --git a/apps/web/src/dialogs/item-dialog.tsx b/apps/web/src/dialogs/item-dialog.tsx index 2d403fda2..2db901c21 100644 --- a/apps/web/src/dialogs/item-dialog.tsx +++ b/apps/web/src/dialogs/item-dialog.tsx @@ -30,6 +30,8 @@ import { useStore as useNoteStore } from "../stores/note-store"; import { useStore as useAppStore } from "../stores/app-store"; import { Color, Tag } from "@notesnook/core"; import { strings } from "@notesnook/intl"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../common/toasts"; type ItemDialogProps = BaseDialogProps & { title: string; @@ -97,6 +99,11 @@ export const CreateTagDialog = { title: strings.addTag(), subtitle: strings.addTagDesc() }).then(async (title) => { + const result = await isFeatureAvailable("tags"); + if (!result.isAllowed) { + return showFeatureNotAllowedToast(result); + } + if ( !title || !(await db.tags.add({ title }).catch((e) => { diff --git a/apps/web/src/dialogs/settings/backup-export-settings.ts b/apps/web/src/dialogs/settings/backup-export-settings.ts index 5b009bde1..fd7fcebbc 100644 --- a/apps/web/src/dialogs/settings/backup-export-settings.ts +++ b/apps/web/src/dialogs/settings/backup-export-settings.ts @@ -80,7 +80,6 @@ export const BackupExportSettings: SettingsGroup[] = [ key: "auto-backup", title: strings.automaticBackups(), description: strings.automaticBackupsDesc(), - // isHidden: () => !isUserPremium(), onStateChange: (listener) => useSettingStore.subscribe((s) => s.backupReminderOffset, listener), components: [ diff --git a/apps/web/src/dialogs/settings/behaviour-settings.ts b/apps/web/src/dialogs/settings/behaviour-settings.ts index 2b8ab9a01..87c262d71 100644 --- a/apps/web/src/dialogs/settings/behaviour-settings.ts +++ b/apps/web/src/dialogs/settings/behaviour-settings.ts @@ -24,18 +24,18 @@ import { useStore as useSettingStore } from "../../stores/setting-store"; import dayjs from "dayjs"; -import { isUserPremium } from "../../hooks/use-is-user-premium"; import { TimeFormat } from "@notesnook/core"; import { TrashCleanupInterval } from "@notesnook/core"; import { strings } from "@notesnook/intl"; import { BuyDialog } from "../buy-dialog"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../../common/toasts"; export const BehaviourSettings: SettingsGroup[] = [ { key: "general", section: "behaviour", header: strings.general(), - isHidden: () => !isUserPremium(), settings: [ { key: "default-sidebar-tab", @@ -47,9 +47,12 @@ export const BehaviourSettings: SettingsGroup[] = [ components: [ { type: "dropdown", - onSelectionChanged: (value) => { - if (!isUserPremium()) { - BuyDialog.show({}); + onSelectionChanged: async (value) => { + const defaultSidebarTab = await isFeatureAvailable( + "defaultSidebarTab" + ); + if (!defaultSidebarTab.isAllowed) { + BuyDialog.show({ plan: defaultSidebarTab.availableOn }); return; } @@ -74,8 +77,15 @@ export const BehaviourSettings: SettingsGroup[] = [ components: [ { type: "dropdown", - onSelectionChanged: (value) => - useSettingStore.getState().setImageCompression(parseInt(value)), + onSelectionChanged: async (value) => { + if (value === ImageCompressionOptions.DISABLE.toString()) { + const result = await isFeatureAvailable("fullQualityImages"); + if (!result.isAllowed) + return showFeatureNotAllowedToast(result); + } + + useSettingStore.getState().setImageCompression(parseInt(value)); + }, selectedOption: () => useSettingStore.getState().imageCompression.toString(), options: [ @@ -159,9 +169,12 @@ export const BehaviourSettings: SettingsGroup[] = [ components: [ { type: "dropdown", - onSelectionChanged: (value) => { - if (!isUserPremium()) { - BuyDialog.show({}); + onSelectionChanged: async (value) => { + const disableTrashCleanup = await isFeatureAvailable( + "disableTrashCleanup" + ); + if (value === "-1" && !disableTrashCleanup.isAllowed) { + BuyDialog.show({ plan: disableTrashCleanup.availableOn }); return; } @@ -178,7 +191,7 @@ export const BehaviourSettings: SettingsGroup[] = [ { value: "7", title: strings.days(7) }, { value: "30", title: strings.days(30) }, { value: "365", title: strings.days(365) }, - { value: "-1", title: strings.never(), premium: true } + { value: "-1", title: strings.never() } ] } ] diff --git a/apps/web/src/dialogs/settings/components/customize-toolbar.tsx b/apps/web/src/dialogs/settings/components/customize-toolbar.tsx index a30e7f61d..1948679e9 100644 --- a/apps/web/src/dialogs/settings/components/customize-toolbar.tsx +++ b/apps/web/src/dialogs/settings/components/customize-toolbar.tsx @@ -59,12 +59,14 @@ import { PresetId } from "../../../common/toolbar-config"; import { showToast } from "../../../utils/toast"; -import { isUserPremium } from "../../../hooks/use-is-user-premium"; import { Pro } from "../../../components/icons"; - import { Icon } from "@notesnook/ui"; -import { CURRENT_TOOLBAR_VERSION } from "@notesnook/common"; +import { + CURRENT_TOOLBAR_VERSION, + useIsFeatureAvailable +} from "@notesnook/common"; import { strings } from "@notesnook/intl"; +import { showFeatureNotAllowedToast } from "../../../common/toasts"; export function CustomizeToolbar() { const sensors = useSensors( @@ -77,6 +79,7 @@ export function CustomizeToolbar() { const [activeItem, setActiveItem] = useState(); const [currentPreset, setCurrentPreset] = useState(); const { setToolbarConfig } = useToolbarConfig(); + const customToolbarPreset = useIsFeatureAvailable("customToolbarPreset"); useEffect(() => { if (!currentPreset) return; @@ -131,18 +134,12 @@ export function CustomizeToolbar() { value={preset.id} checked={preset.id === currentPreset.id} defaultChecked={preset.id === currentPreset.id} - disabled={preset.id === "custom" && !isUserPremium()} + disabled={ + preset.id === "custom" && !customToolbarPreset?.isAllowed + } style={{ accentColor: "var(--accent)" }} onChange={async (e) => { const { value } = e.target; - if (preset.id === "custom" && !isUserPremium()) { - showToast( - "info", - strings.upgradeToProToUseFeature("customPresets") - ); - return; - } - console.log("CHANGE PRESET", value); setCurrentPreset(getPreset(value as PresetId)); }} /> @@ -155,7 +152,7 @@ export function CustomizeToolbar() { > {preset.title} - {preset.id === "custom" && !isUserPremium() ? ( + {preset.id === "custom" && !customToolbarPreset?.isAllowed ? ( ) : null} @@ -185,11 +182,8 @@ export function CustomizeToolbar() { sensors={sensors} collisionDetection={closestCenter} cancelDrop={() => { - if (!isUserPremium()) { - showToast( - "error", - strings.upgradeToProToUseFeature("customizeToolbar") - ); + if (!customToolbarPreset?.isAllowed) { + showFeatureNotAllowedToast(customToolbarPreset); return true; } return false; diff --git a/apps/web/src/dialogs/settings/editor-settings.ts b/apps/web/src/dialogs/settings/editor-settings.ts index 2a63d12e5..bcb64ff18 100644 --- a/apps/web/src/dialogs/settings/editor-settings.ts +++ b/apps/web/src/dialogs/settings/editor-settings.ts @@ -32,6 +32,8 @@ import { CustomizeToolbar } from "./components/customize-toolbar"; import { DictionaryWords } from "./components/dictionary-words"; import { strings } from "@notesnook/intl"; import { isMac } from "../../utils/platform"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../../common/toasts"; export const EditorSettings: SettingsGroup[] = [ { @@ -119,7 +121,11 @@ export const EditorSettings: SettingsGroup[] = [ { type: "toggle", isToggled: () => useSettingStore.getState().markdownShortcuts, - toggle: () => useSettingStore.getState().toggleMarkdownShortcuts() + toggle: async () => { + const result = await isFeatureAvailable("markdownShortcuts"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + useSettingStore.getState().toggleMarkdownShortcuts(); + } } ] }, @@ -133,7 +139,11 @@ export const EditorSettings: SettingsGroup[] = [ { type: "toggle", isToggled: () => useSettingStore.getState().fontLigatures, - toggle: () => useSettingStore.getState().toggleFontLigatures() + toggle: async () => { + const result = await isFeatureAvailable("fontLigatures"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + useSettingStore.getState().toggleFontLigatures(); + } } ] } diff --git a/apps/web/src/dialogs/settings/index.tsx b/apps/web/src/dialogs/settings/index.tsx index 1ca89af29..47932a281 100644 --- a/apps/web/src/dialogs/settings/index.tsx +++ b/apps/web/src/dialogs/settings/index.tsx @@ -53,7 +53,6 @@ import { } from "./types"; import { ProfileSettings } from "./profile-settings"; import { AuthenticationSettings } from "./auth-settings"; -import { useIsUserPremium } from "../../hooks/use-is-user-premium"; import { useStore as useUserStore } from "../../stores/user-store"; import { SyncSettings } from "./sync-settings"; import { BehaviourSettings } from "./behaviour-settings"; @@ -433,7 +432,6 @@ function SettingItem(props: { item: Setting }) { const { item } = props; const [state, setState] = useState(); const [workIndex, setWorkIndex] = useState(); - const isUserPremium = useIsUserPremium(); useEffect(() => { if (!item.onStateChange) return; @@ -545,12 +543,7 @@ function SettingItem(props: { item: Setting }) { /> ); case "dropdown": - return ( - - ); + return ; case "input": return component.inputType === "number" ? ( props.selectedOption(), [props]); return ( @@ -629,11 +620,7 @@ function SelectComponent( } > {options.map((option) => ( - ))} diff --git a/apps/web/src/dialogs/settings/types.ts b/apps/web/src/dialogs/settings/types.ts index 345f1da30..8d40fff29 100644 --- a/apps/web/src/dialogs/settings/types.ts +++ b/apps/web/src/dialogs/settings/types.ts @@ -124,7 +124,7 @@ export type ToggleSettingComponent = BaseSettingComponent<"toggle"> & { }; export type DropdownSettingComponent = BaseSettingComponent<"dropdown"> & { - options: { value: string | number; title: string; premium?: boolean }[]; + options: { value: string | number; title: string }[]; selectedOption: () => string | number | Promise; onSelectionChanged: (value: string) => void | Promise; }; diff --git a/apps/web/src/dialogs/settings/vault-settings.tsx b/apps/web/src/dialogs/settings/vault-settings.tsx index b9e56c5b8..becda4006 100644 --- a/apps/web/src/dialogs/settings/vault-settings.tsx +++ b/apps/web/src/dialogs/settings/vault-settings.tsx @@ -23,8 +23,6 @@ import { useStore as useNotesStore } from "../../stores/note-store"; import Vault from "../../common/vault"; import { showToast } from "../../utils/toast"; import { db } from "../../common/db"; -import { isUserPremium } from "../../hooks/use-is-user-premium"; -import { BuyDialog } from "../buy-dialog/buy-dialog"; import { strings } from "@notesnook/intl"; export const VaultSettings: SettingsGroup[] = [ @@ -44,11 +42,9 @@ export const VaultSettings: SettingsGroup[] = [ type: "button", title: strings.create(), action: () => { - if (!isUserPremium()) BuyDialog.show({}); - else - Vault.createVault().then((res) => { - useAppStore.getState().setIsVaultCreated(res); - }); + Vault.createVault().then((res) => { + useAppStore.getState().setIsVaultCreated(res); + }); }, variant: "secondary" } diff --git a/apps/web/src/stores/app-store.ts b/apps/web/src/stores/app-store.ts index 9f1671c50..a3e65aadd 100644 --- a/apps/web/src/stores/app-store.ts +++ b/apps/web/src/stores/app-store.ts @@ -44,6 +44,8 @@ import { useEditorStore } from "./editor-store"; import { useEditorManager } from "../components/editor/manager"; import { exitFullscreen } from "../utils/fullscreen"; import { NavigationTabItem } from "../components/navigation-menu"; +import { isFeatureAvailable } from "@notesnook/common"; +import { showFeatureNotAllowedToast } from "../common/toasts"; type SyncState = | "synced" @@ -251,11 +253,14 @@ class AppStore extends BaseStore { }; addToShortcuts = async (item: { type: "tag" | "notebook"; id: string }) => { - if (await db.shortcuts.exists(item.id)) { + if (db.shortcuts.exists(item.id)) { await db.shortcuts.remove(item.id); this.refreshNavItems(); showToast("success", strings.shortcutRemoved()); } else { + const result = await isFeatureAvailable("shortcuts"); + if (!result.isAllowed) return showFeatureNotAllowedToast(result); + await db.shortcuts.add({ itemType: item.type, itemId: item.id diff --git a/apps/web/src/utils/toast.tsx b/apps/web/src/utils/toast.tsx index 9c31f29e5..bffa5b658 100644 --- a/apps/web/src/utils/toast.tsx +++ b/apps/web/src/utils/toast.tsx @@ -36,7 +36,7 @@ const ToastIcons = { info: Info }; -function showToast( +export function showToast( type: ToastType, message: string, actions?: ToastAction[], @@ -102,8 +102,6 @@ function ToastContainer(props: ToastContainerProps) { ); } -export { showToast }; - function ToastIcon({ type }: { type: ToastType }) { const IconComponent = ToastIcons[type]; return ( diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index 52ded516f..9f314a13e 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -20,3 +20,4 @@ along with this program. If not, see . export * from "./use-time-ago.js"; export * from "./use-promise.js"; export * from "./use-resolved-item.js"; +export * from "./use-is-feature-available.js"; diff --git a/packages/common/src/utils/export-notes.ts b/packages/common/src/utils/export-notes.ts index ef30d4c39..7b9ac630b 100644 --- a/packages/common/src/utils/export-notes.ts +++ b/packages/common/src/utils/export-notes.ts @@ -25,8 +25,6 @@ import { ResolveInternalLink, isImage, isWebClip, - CHECK_IDS, - checkIsUserPremium, FilteredSelector, EMPTY_CONTENT } from "@notesnook/core"; @@ -66,8 +64,6 @@ export async function* exportNotes( } ) { const { format } = options; - if (format !== "txt" && !(await checkIsUserPremium(CHECK_IDS.noteExport))) - return; const pathTree = new PathTree(); const notePathMap: Map = new Map(); @@ -175,8 +171,6 @@ export async function* exportNote( } ) { const { format } = options; - if (format !== "txt" && !(await checkIsUserPremium(CHECK_IDS.noteExport))) - return; const attachmentsRoot = "attachments"; const filename = sanitizeFilename(note.title || "Untitled", { diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 12944d932..4bee44237 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -29,3 +29,4 @@ export * from "./export-notes.js"; export * from "./dataurl.js"; export * from "./tab-session-history.js"; export * from "./keybindings.js"; +export * from "./is-feature-available.js"; diff --git a/packages/core/src/api/vault.ts b/packages/core/src/api/vault.ts index 7cb4c84df..e58e23d3a 100644 --- a/packages/core/src/api/vault.ts +++ b/packages/core/src/api/vault.ts @@ -19,7 +19,7 @@ along with this program. If not, see . import { Cipher } from "@notesnook/crypto"; import Database from "./index.js"; -import { CHECK_IDS, EV, EVENTS, checkIsUserPremium } from "../common.js"; +import { EV, EVENTS } from "../common.js"; import { isCipher } from "../utils/crypto.js"; import { Note, NoteContent } from "../types.js"; import { logger } from "../logger.js"; @@ -67,8 +67,6 @@ export default class Vault { } async create(password: string) { - if (!(await checkIsUserPremium(CHECK_IDS.vaultAdd))) return; - const vaultKey = await this.getKey(); if (!vaultKey || !isCipher(vaultKey)) { const encryptedData = await this.db @@ -174,8 +172,6 @@ export default class Vault { * Locks (add to vault) a note */ async add(noteId: string) { - if (!(await checkIsUserPremium(CHECK_IDS.vaultAdd))) return; - await this.lockNote({ id: noteId }, await this.getVaultPassword()); await this.db.noteHistory.clearSessions(noteId); } diff --git a/packages/core/src/collections/colors.ts b/packages/core/src/collections/colors.ts index d36b78784..31b00b5f7 100644 --- a/packages/core/src/collections/colors.ts +++ b/packages/core/src/collections/colors.ts @@ -24,7 +24,6 @@ import Database from "../api/index.js"; import { sanitizeTag } from "./tags.js"; import { SQLCollection } from "../database/sql-collection.js"; import { isFalse } from "../database/index.js"; -import { CHECK_IDS, checkIsUserPremium } from "../common.js"; export const DefaultColors: Record = { red: "#f44336", @@ -85,7 +84,7 @@ export class Colors implements ICollection { await this.collection.update([oldColor.id], item); return oldColor.id; } - if (!(await checkIsUserPremium(CHECK_IDS.noteColor))) return; + if (!this.db.features.allowed("colors")) return; const id = item.id || getId(item.dateCreated); await this.collection.upsert({ diff --git a/packages/core/src/collections/notebooks.ts b/packages/core/src/collections/notebooks.ts index 784c52be0..14f9864bf 100644 --- a/packages/core/src/collections/notebooks.ts +++ b/packages/core/src/collections/notebooks.ts @@ -25,11 +25,6 @@ import { SQLCollection } from "../database/sql-collection.js"; import { DatabaseSchema, isFalse } from "../database/index.js"; import { Kysely, sql, Transaction } from "@streetwriters/kysely"; import { deleteItems } from "../utils/array.js"; -import { - CHECK_IDS, - checkIsUserPremium, - FREE_NOTEBOOKS_LIMIT -} from "../common.js"; export class Notebooks implements ICollection { name = "notebooks"; @@ -64,12 +59,7 @@ export class Notebooks implements ICollection { if (oldNotebook && isTrashItem(oldNotebook)) throw new Error("Cannot modify trashed notebooks."); - if ( - !oldNotebook && - (await this.all.count()) >= FREE_NOTEBOOKS_LIMIT && - !(await checkIsUserPremium(CHECK_IDS.notebookAdd)) - ) - return; + if (!oldNotebook && !(await this.db.features.allowed("notebooks"))) return; const mergedNotebook: Partial = { ...oldNotebook, diff --git a/packages/core/src/collections/reminders.ts b/packages/core/src/collections/reminders.ts index 5d5db6113..bf6759a0c 100644 --- a/packages/core/src/collections/reminders.ts +++ b/packages/core/src/collections/reminders.ts @@ -99,6 +99,16 @@ export class Reminders implements ICollection { ); } + get active() { + return this.collection.createFilter( + (qb) => + qb + .where(isFalse("deleted")) + .where((eb) => eb.parens(createIsReminderActiveQuery())), + this.db.options?.batchSize + ); + } + exists(itemId: string) { return this.collection.exists(itemId); } @@ -276,7 +286,7 @@ export function createUpcomingReminderTimeQuery(unix = "now") { const lastSelectedDay = sql`(SELECT MAX(value) FROM json_each(selectedDays))`; const monthDate = sql`strftime('%m-%d%H:%M', date / 1000, 'unixepoch', 'localtime')`; - return sql`CASE + return sql`CASE WHEN mode = 'once' THEN date / 1000 WHEN recurringMode = 'year' THEN strftime('%s', @@ -286,11 +296,11 @@ export function createUpcomingReminderTimeQuery(unix = "now") { ) WHEN recurringMode = 'day' THEN strftime('%s', - ${dateNow} || ${time}, + ${dateNow} || ${time}, IIF(${dateTime} <= ${dateTimeNow}, '+1 day', '+0 day'), 'utc' ) - WHEN recurringMode = 'week' AND selectedDays IS NOT NULL AND json_array_length(selectedDays) > 0 THEN + WHEN recurringMode = 'week' AND selectedDays IS NOT NULL AND json_array_length(selectedDays) > 0 THEN CASE WHEN ${weekDayNow} > ${lastSelectedDay} OR (${weekDayNow} == ${lastSelectedDay} AND ${dateTime} <= ${dateTimeNow}) @@ -316,7 +326,7 @@ export function createIsReminderActiveQuery(now = "now") { return sql`IIF( (disabled IS NULL OR disabled = 0) AND (mode != 'once' - OR datetime(date / 1000, 'unixepoch', 'localtime') > datetime(${now}) + OR datetime(date / 1000, 'unixepoch', 'localtime') > datetime(${now}) OR (snoozeUntil IS NOT NULL AND datetime(snoozeUntil / 1000, 'unixepoch', 'localtime') > datetime(${now})) ), 1, 0)`.$castTo(); diff --git a/packages/core/src/collections/tags.ts b/packages/core/src/collections/tags.ts index dedfef48f..e14752e9c 100644 --- a/packages/core/src/collections/tags.ts +++ b/packages/core/src/collections/tags.ts @@ -24,7 +24,6 @@ import { ICollection } from "./collection.js"; import { SQLCollection } from "../database/sql-collection.js"; import { isFalse } from "../database/index.js"; import { sql } from "@streetwriters/kysely"; -import { CHECK_IDS, checkIsUserPremium } from "../common.js"; export class Tags implements ICollection { name = "tags"; @@ -65,11 +64,7 @@ export class Tags implements ICollection { await this.collection.update([oldTag.id], item); return oldTag.id; } - if ( - (await this.all.count()) >= 5 && - !(await checkIsUserPremium(CHECK_IDS.noteTag)) - ) - return; + if (!(await this.db.features.allowed("tags"))) return; const id = item.id || getId(); await this.collection.upsert({ diff --git a/packages/core/src/collections/trash.ts b/packages/core/src/collections/trash.ts index 1583eff01..b4c4e6777 100644 --- a/packages/core/src/collections/trash.ts +++ b/packages/core/src/collections/trash.ts @@ -29,11 +29,6 @@ import { } from "../utils/grouping.js"; import { sql } from "@streetwriters/kysely"; import { MAX_SQL_PARAMETERS } from "../database/sql-collection.js"; -import { - CHECK_IDS, - checkIsUserPremium, - FREE_NOTEBOOKS_LIMIT -} from "../common.js"; import { withSubNotebooks } from "./notebooks.js"; export default class Trash { @@ -202,13 +197,11 @@ export default class Trash { } if (notebookIds.length > 0) { - const notebooksLimitReached = - (await this.db.notebooks.all.count()) + notebookIds.length > - FREE_NOTEBOOKS_LIMIT; - const isUserPremium = await checkIsUserPremium(CHECK_IDS.notebookAdd); - if (notebooksLimitReached && !isUserPremium) { + const expectedCount = + (await this.db.notebooks.all.count()) + notebookIds.length; + if (!(await this.db.features.allowed("notebooks", expectedCount))) return false; - } + const ids = [...notebookIds, ...(await this.subNotebooks(notebookIds))]; await this.db.notebooks.collection.update(ids, { type: "notebook", diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index df5985645..e640f8ccf 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -21,17 +21,6 @@ import EventManager from "./utils/event-manager.js"; export const EV = new EventManager(); -export async function checkIsUserPremium(type: string) { - // if (process.env.NODE_ENV === "test") return true; - - const results = await EV.publishWithResult<{ type: string; result: boolean }>( - EVENTS.userCheckStatus, - type - ); - if (typeof results === "boolean") return results; - return results.some((r) => r.type === type && r.result === true); -} - export const SYNC_CHECK_IDS = { autoSync: "autoSync", sync: "sync" @@ -80,17 +69,7 @@ export function sendMigrationProgressEvent( export const CLIENT_ID = "notesnook"; -export const CHECK_IDS = { - noteColor: "note:color", - noteTag: "note:tag", - noteExport: "note:export", - vaultAdd: "vault:add", - notebookAdd: "notebook:add", - backupEncrypt: "backup:encrypt" -}; - export const EVENTS = { - userCheckStatus: "user:checkStatus", userSubscriptionUpdated: "user:subscriptionUpdated", userEmailConfirmed: "user:emailConfirmed", userLoggedIn: "user:loggedIn", diff --git a/packages/editor/src/extensions/callout/callout.ts b/packages/editor/src/extensions/callout/callout.ts index 0d66be1f2..bbd34b1c0 100644 --- a/packages/editor/src/extensions/callout/callout.ts +++ b/packages/editor/src/extensions/callout/callout.ts @@ -25,6 +25,7 @@ import { Paragraph } from "../paragraph/index.js"; import { Heading } from "../heading/index.js"; import { TextSelection } from "@tiptap/pm/state"; import { Fragment } from "@tiptap/pm/model"; +import { hasPermission } from "../../types.js"; declare module "@tiptap/core" { interface Commands { @@ -132,6 +133,8 @@ export const Callout = Node.create({ setCallout: (attributes) => ({ tr, state }) => { + if (!hasPermission("setCallout")) return false; + const { selection } = state; const start = selection.from; const end = selection.to; @@ -176,6 +179,7 @@ export const Callout = Node.create({ new InputRule({ find: CALLOUT_REGEX, handler: ({ state, range, match }) => { + if (!hasPermission("setCallout", true)) return null; if (match.length === 1) return null; const calloutType = (match[1] || "info") as CalloutType; diff --git a/packages/editor/src/extensions/outline-list/outline-list.ts b/packages/editor/src/extensions/outline-list/outline-list.ts index cfc439a07..c03e3ddb8 100644 --- a/packages/editor/src/extensions/outline-list/outline-list.ts +++ b/packages/editor/src/extensions/outline-list/outline-list.ts @@ -18,6 +18,7 @@ along with this program. If not, see . */ import { tiptapKeys } from "@notesnook/common"; +import { hasPermission } from "../../types.js"; import { getParentAttributes } from "../../utils/prosemirror.js"; import { Node, mergeAttributes, wrappingInputRule } from "@tiptap/core"; @@ -83,6 +84,8 @@ export const OutlineList = Node.create({ toggleOutlineList: () => ({ chain }) => { + if (!hasPermission("toggleOutlineList")) return false; + return chain() .toggleList( this.name, @@ -107,22 +110,35 @@ export const OutlineList = Node.create({ }, addInputRules() { - return [ - wrappingInputRule({ - find: inputRegex, - type: this.type, - keepMarks: this.options.keepMarks, - keepAttributes: this.options.keepAttributes, - editor: this.editor, - getAttributes: () => { - return getParentAttributes( - this.editor, - this.options.keepMarks, - this.options.keepAttributes - ); - } - }) - ]; + const inputRule = wrappingInputRule({ + find: inputRegex, + type: this.type, + keepMarks: this.options.keepMarks, + keepAttributes: this.options.keepAttributes, + editor: this.editor, + getAttributes: () => { + return getParentAttributes( + this.editor, + this.options.keepMarks, + this.options.keepAttributes + ); + } + }); + const oldHandler = inputRule.handler; + inputRule.handler = ({ state, range, match, chain, can, commands }) => { + if (!hasPermission("toggleOutlineList", true)) return; + + oldHandler({ + state, + range, + match, + chain, + can, + commands + }); + }; + + return [inputRule]; }, addNodeView() { return ({ node, HTMLAttributes }) => { diff --git a/packages/editor/src/extensions/task-list/task-list.ts b/packages/editor/src/extensions/task-list/task-list.ts index a38acdf94..4b3b89064 100644 --- a/packages/editor/src/extensions/task-list/task-list.ts +++ b/packages/editor/src/extensions/task-list/task-list.ts @@ -41,6 +41,7 @@ import { Node as ProsemirrorNode } from "@tiptap/pm/model"; import { TaskItemNode } from "../task-item/index.js"; import { ListItem } from "../list-item/list-item.js"; import { tiptapKeys } from "@notesnook/common"; +import { hasPermission } from "../../types.js"; type TaskListStats = { checked: number; total: number }; export type TaskListAttributes = { @@ -121,6 +122,8 @@ export const TaskListNode = TaskList.extend({ toggleTaskList: () => ({ editor, chain, state, tr }) => { + if (!hasPermission("toggleTaskList")) return false; + const { $from, $to } = state.selection; chain() @@ -338,6 +341,8 @@ export const TaskListNode = TaskList.extend({ }); const oldHandler = inputRule.handler; inputRule.handler = ({ state, range, match, chain, can, commands }) => { + if (!hasPermission("toggleTaskList", true)) return; + const $from = state.selection.$from; const parentNode = $from.node($from.depth - 1); if (parentNode.type.name === ListItem.name) { diff --git a/packages/editor/src/hooks/use-permission-handler.ts b/packages/editor/src/hooks/use-permission-handler.ts index f92ed2b34..ba47a84c3 100644 --- a/packages/editor/src/hooks/use-permission-handler.ts +++ b/packages/editor/src/hooks/use-permission-handler.ts @@ -21,14 +21,16 @@ import { UnionCommands } from "@tiptap/core"; import { useEffect } from "react"; import { PermissionRequestEvent } from "../types.js"; -export type Claims = "premium"; +export type Claims = keyof typeof ClaimsMap; export type PermissionHandlerOptions = { claims: Record; - onPermissionDenied: (claim: Claims, id: keyof UnionCommands) => void; + onPermissionDenied: (claim: Claims, silent: boolean) => void; }; -const ClaimsMap: Record = { - premium: ["insertImage", "insertAttachment"] +const ClaimsMap = { + callout: ["setCallout"] as (keyof UnionCommands)[], + outlineList: ["toggleOutlineList"] as (keyof UnionCommands)[], + taskList: ["toggleTaskList"] as (keyof UnionCommands)[] }; export function usePermissionHandler(options: PermissionHandlerOptions) { @@ -37,7 +39,7 @@ export function usePermissionHandler(options: PermissionHandlerOptions) { useEffect(() => { function onPermissionRequested(ev: Event) { const { - detail: { id } + detail: { id, silent } } = ev as PermissionRequestEvent; for (const key in ClaimsMap) { @@ -47,7 +49,7 @@ export function usePermissionHandler(options: PermissionHandlerOptions) { if (commands.indexOf(id) <= -1) continue; if (claims[claim]) continue; - onPermissionDenied(claim, id); + onPermissionDenied(claim, silent); ev.preventDefault(); break; } diff --git a/packages/editor/src/toolbar/tools/block.tsx b/packages/editor/src/toolbar/tools/block.tsx index 21ae233e3..53fca3027 100644 --- a/packages/editor/src/toolbar/tools/block.tsx +++ b/packages/editor/src/toolbar/tools/block.tsx @@ -322,12 +322,7 @@ const uploadImageFromURLMobile = (editor: Editor): MenuItem => ({ component: ({ onClick }) => ( { - editor - .requestPermission("insertImage") - ?.chain() - .focus() - .insertImage(image) - .run(); + editor?.chain().focus().insertImage(image).run(); onClick?.(); }} onClose={() => { @@ -350,12 +345,7 @@ const uploadImageFromURL = (editor: Editor): MenuItem => ({ popup: (hide) => ( { - editor - .requestPermission("insertImage") - ?.chain() - .focus() - .insertImage(image) - .run(); + editor?.chain().focus().insertImage(image).run(); hide(); }} onClose={hide} diff --git a/packages/editor/src/types.ts b/packages/editor/src/types.ts index ac3d91c02..a906b2144 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -20,21 +20,14 @@ along with this program. If not, see . import { UnionCommands, Editor as TiptapEditor } from "@tiptap/core"; import { Mutex } from "async-mutex"; -export type PermissionRequestEvent = CustomEvent<{ id: keyof UnionCommands }>; +export type PermissionRequestEvent = CustomEvent<{ + id: keyof UnionCommands; + silent: boolean; +}>; export class Editor extends TiptapEditor { private mutex: Mutex = new Mutex(); - /** - * Request permission before executing a command to make sure user - * is allowed to perform the action. - * @param id the command id to get permission for - * @returns latest editor instance - */ - requestPermission(id: keyof UnionCommands): TiptapEditor | undefined { - return hasPermission(id) ? this : undefined; - } - /** * Performs editor state changes in a thread-safe manner using a mutex * ensuring that all changes are applied sequentially. Use this when @@ -45,9 +38,12 @@ export class Editor extends TiptapEditor { } } -export function hasPermission(id: keyof UnionCommands): boolean { +export function hasPermission( + id: keyof UnionCommands, + silent = false +): boolean { const event = new CustomEvent("permissionrequest", { - detail: { id }, + detail: { id, silent }, cancelable: true }); return window.dispatchEvent(event); diff --git a/packages/intl/locale/en.po b/packages/intl/locale/en.po index 2e3dbb527..fbeed7a9f 100644 --- a/packages/intl/locale/en.po +++ b/packages/intl/locale/en.po @@ -2739,8 +2739,8 @@ msgid "File mismatch" msgstr "File mismatch" #: src/strings.ts:944 -msgid "File size should be less than {sizeInMB}MB" -msgstr "File size should be less than {sizeInMB}MB" +msgid "File size should be less than {sizeInMB}" +msgstr "File size should be less than {sizeInMB}" #: src/strings.ts:942 msgid "File too big" @@ -3153,8 +3153,8 @@ msgid "Image settings" msgstr "Image settings" #: src/strings.ts:946 -msgid "Image size should be less than {sizeInMB}MB" -msgstr "Image size should be less than {sizeInMB}MB" +msgid "Image size should be less than {sizeInMB}" +msgstr "Image size should be less than {sizeInMB}" #: src/strings.ts:754 msgid "Images" diff --git a/packages/intl/locale/pseudo-LOCALE.po b/packages/intl/locale/pseudo-LOCALE.po index 8ffa3c62c..907283151 100644 --- a/packages/intl/locale/pseudo-LOCALE.po +++ b/packages/intl/locale/pseudo-LOCALE.po @@ -2728,7 +2728,7 @@ msgid "File mismatch" msgstr "" #: src/strings.ts:944 -msgid "File size should be less than {sizeInMB}MB" +msgid "File size should be less than {sizeInMB}" msgstr "" #: src/strings.ts:942 @@ -3133,7 +3133,7 @@ msgid "Image settings" msgstr "" #: src/strings.ts:946 -msgid "Image size should be less than {sizeInMB}MB" +msgid "Image size should be less than {sizeInMB}" msgstr "" #: src/strings.ts:754 diff --git a/packages/intl/src/strings.ts b/packages/intl/src/strings.ts index 23d2d3a4e..68f8414a0 100644 --- a/packages/intl/src/strings.ts +++ b/packages/intl/src/strings.ts @@ -940,10 +940,10 @@ $headline$: Use starting line of the note as title.`, encryptingAttachmentDesc: (name = "attachment") => t`Please wait while we encrypt ${name} for upload.`, fileTooLarge: () => t`File too big`, - fileTooLargeDesc: (sizeInMB: number) => - t`File size should be less than ${sizeInMB}MB`, - imageTooLarge: (sizeInMB: number) => - t`Image size should be less than ${sizeInMB}MB`, + fileTooLargeDesc: (sizeInMB: string) => + t`File size should be less than ${sizeInMB}`, + imageTooLarge: (sizeInMB: string) => + t`Image size should be less than ${sizeInMB}`, failToOpen: () => t`Failed to open`, fileMismatch: () => t`File mismatch`, noNoteProperties: () => t`Start writing to create a new note`,