web: add feature limits in almost all places

This commit is contained in:
Abdullah Atta
2025-07-14 12:35:22 +05:00
parent e6dc5ddfed
commit bce063e1bd
49 changed files with 295 additions and 314 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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();

View File

@@ -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<ImageCompressionOptions>(
"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<Attachment | undefined> {
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<Attachment | undefined> {
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);

View File

@@ -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
});
}
});

View File

@@ -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<T extends { id: string }>(
const [activeItem, setActiveItem] = useState<T>();
const [order, setOrder] = usePersistentState<string[]>(orderKey, _order());
const orderedItems = orderItems(items, order);
const customizableSidebar = useIsFeatureAvailable("customizableSidebar");
useEffect(() => {
setOrder(_order());
@@ -912,10 +915,10 @@ function ReorderableList<T extends { id: string }>(
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) => {

View File

@@ -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) {

View File

@@ -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
);

View File

@@ -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);
}
},

View File

@@ -19,10 +19,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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 (

View File

@@ -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<boolean> & {
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,

View File

@@ -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<HTMLInputElement>(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 && (
<Pro size={18} color="accent" sx={{ ml: 1 }} />
)}
</Label>

View File

@@ -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<boolean> & { 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();

View File

@@ -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<false> & {
couponCode?: string;
plan?: PlanId;
plan?: SubscriptionPlan;
onClose: () => void;
};
@@ -115,7 +115,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog(
>
<CheckoutSideBar
onClose={() => onClose(false)}
initialPlan={plan || "free"}
initialPlan={plan || SubscriptionPlan.FREE}
user={user}
/>
</ScopedThemeProvider>
@@ -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 (
<PlansList
selectedPlan={selectedPlan?.id || initialPlan || "free"}
selectedPlan={selectedPlan?.id || initialPlan}
onPlansLoaded={(plans) => {
// if (!initialPlan || showPlans) return;
// const plan = plans.find((p) => p.id === initialPlan);
@@ -476,7 +476,7 @@ function SelectedPlan(props: SelectedPlanProps) {
>
{plan.title}
</Text>
{plan.id === "education" && (
{plan.id === SubscriptionPlan.EDUCATION && (
<Link
href="https://notesnook.com/education"
target="_blank"

View File

@@ -20,14 +20,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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;
};

View File

@@ -20,20 +20,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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: [

View File

@@ -17,6 +17,7 @@ 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 { 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;

View File

@@ -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<string | false>;
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

View File

@@ -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<false | File[]> & {
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);
}}
/>
<span style={{ marginLeft: 5 }}>
Enable compression (recommended)

View File

@@ -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(),

View File

@@ -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<false | string> & {
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) => {

View File

@@ -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: [

View File

@@ -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() }
]
}
]

View File

@@ -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<TreeNode>();
const [currentPreset, setCurrentPreset] = useState<Preset>();
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}
</span>
{preset.id === "custom" && !isUserPremium() ? (
{preset.id === "custom" && !customToolbarPreset?.isAllowed ? (
<Pro color="accent" size={18} sx={{ ml: 1 }} />
) : null}
</Label>
@@ -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;

View File

@@ -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();
}
}
]
}

View File

@@ -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<unknown>();
const [workIndex, setWorkIndex] = useState<number>();
const isUserPremium = useIsUserPremium();
useEffect(() => {
if (!item.onStateChange) return;
@@ -545,12 +543,7 @@ function SettingItem(props: { item: Setting }) {
/>
);
case "dropdown":
return (
<SelectComponent
{...component}
isUserPremium={isUserPremium}
/>
);
return <SelectComponent {...component} />;
case "input":
return component.inputType === "number" ? (
<Input
@@ -604,10 +597,8 @@ function SettingItem(props: { item: Setting }) {
);
}
function SelectComponent(
props: DropdownSettingComponent & { isUserPremium: boolean }
) {
const { onSelectionChanged, options, isUserPremium } = props;
function SelectComponent(props: DropdownSettingComponent) {
const { onSelectionChanged, options } = props;
const selectedOption = usePromise(() => props.selectedOption(), [props]);
return (
@@ -629,11 +620,7 @@ function SelectComponent(
}
>
{options.map((option) => (
<option
disabled={option.premium && !isUserPremium}
key={option.value}
value={option.value}
>
<option key={option.value} value={option.value}>
{option.title}
</option>
))}

View File

@@ -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<string | number>;
onSelectionChanged: (value: string) => void | Promise<void>;
};

View File

@@ -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"
}

View File

@@ -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<AppStore> {
};
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

View File

@@ -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 (

View File

@@ -20,3 +20,4 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
export * from "./use-time-ago.js";
export * from "./use-promise.js";
export * from "./use-resolved-item.js";
export * from "./use-is-feature-available.js";

View File

@@ -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<string, string[]> = 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", {

View File

@@ -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";

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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);
}

View File

@@ -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<string, string> = {
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({

View File

@@ -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<Notebook> = {
...oldNotebook,

View File

@@ -99,6 +99,16 @@ export class Reminders implements ICollection {
);
}
get active() {
return this.collection.createFilter<Reminder>(
(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<boolean>();

View File

@@ -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({

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<ReturnType> {
@@ -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;

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<OutlineListOptions>({
toggleOutlineList:
() =>
({ chain }) => {
if (!hasPermission("toggleOutlineList")) return false;
return chain()
.toggleList(
this.name,
@@ -107,22 +110,35 @@ export const OutlineList = Node.create<OutlineListOptions>({
},
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 }) => {

View File

@@ -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) {

View File

@@ -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<Claims, boolean>;
onPermissionDenied: (claim: Claims, id: keyof UnionCommands) => void;
onPermissionDenied: (claim: Claims, silent: boolean) => void;
};
const ClaimsMap: Record<Claims, (keyof UnionCommands)[]> = {
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;
}

View File

@@ -322,12 +322,7 @@ const uploadImageFromURLMobile = (editor: Editor): MenuItem => ({
component: ({ onClick }) => (
<ImageUploadPopup
onInsert={(image) => {
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) => (
<ImageUploadPopup
onInsert={(image) => {
editor
.requestPermission("insertImage")
?.chain()
.focus()
.insertImage(image)
.run();
editor?.chain().focus().insertImage(image).run();
hide();
}}
onClose={hide}

View File

@@ -20,21 +20,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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);

View File

@@ -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"

View File

@@ -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

View File

@@ -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`,