Compare commits

...

39 Commits

Author SHA1 Message Date
Ammar Ahmed
9d36fe7022 mobile: update e2e tests 2026-04-06 11:22:42 +05:00
Ammar Ahmed
614046f8c2 mobile: add support for image compression default setting in mobile 2026-04-06 11:22:40 +05:00
Ammar Ahmed
23bb62c88d mobile: migrate pickers to ts 2026-04-06 11:21:53 +05:00
Abdullah Atta
b58db58e13 Merge pull request #9506 from streetwriters/editor/internal-link-title-in-popup
editor: show internal link's title in hover popup
2026-04-06 09:37:31 +05:00
Ammar Ahmed
195dc18a09 Merge pull request #9649 from streetwriters/feat/copy-id-mobile
mobile: add copy id button in notebook and tag properties
2026-04-06 09:28:44 +05:00
Ammar Ahmed
187086460d mobile: undo debug change 2026-04-06 09:28:01 +05:00
Abdullah Atta
84655cec40 Merge pull request #9650 from streetwriters/fix-internal-links
Fix internal links
2026-04-06 09:25:29 +05:00
01zulfi
49b1dd46d1 core: add support for server controlled monograph urls (#8995)
* core: support monograph.publishUrl field
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: support monograph publish-info endpoint
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: pubilsh-info -> metadata

* monograph: add support for slug

* mobile: use monograph metadata to get publishUrl (#9628)

Signed-off-by: sahil <sahil.aslam011@gmail.com>

* core: use v2 endpoint for publishing monographs

* monograph: separate route for v2 monographs

* core: fix monograph updates

---------

Signed-off-by: sahil <sahil.aslam011@gmail.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
Co-authored-by: SahilAslam001 <sahil.aslam011@gmail.com>
2026-04-06 09:15:15 +05:00
01zulfi
76e14d4434 web: handle multiple internal link types in internal link properties tabs
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-03 14:40:58 +05:00
Ammar Ahmed
f828ab0314 mobile: safely handle multiple internal link types 2026-04-03 13:01:34 +05:00
Ammar Ahmed
8d3d173762 core: db.notes.internalLinks() should only return note internal links 2026-04-03 13:00:51 +05:00
Ammar Ahmed
12644fce28 mobile: add copy id button in notebook and tag properties 2026-04-03 12:20:44 +05:00
Ammar Ahmed
358b499d32 editor: add support for pasting nn:// internal urls 2026-04-02 14:02:24 +05:00
Ammar Ahmed
17864d3325 mobile: add support in editor to fetch internal link data 2026-04-02 13:32:03 +05:00
Ammar Ahmed
c90a2f5179 Merge branch 'master' into beta 2026-04-02 13:07:58 +05:00
01zulfi
47b2569b29 editor: add loading indicator while fetching internal link title
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-31 16:12:20 +05:00
Abdullah Atta
692975d4a0 Merge pull request #8928 from streetwriters/web/inbox-pgp-keys
[NN-5] web: use pgp keys for inbox
2026-03-31 13:32:30 +05:00
Abdullah Atta
6a843a6457 core: fix getInboxKeys 2026-03-31 13:31:37 +05:00
Abdullah Atta
a139bdad1e intl: update strings 2026-03-31 13:31:37 +05:00
Ammar Ahmed
b5dc298d7d mobile: add inbox api settings 2026-03-31 13:31:37 +05:00
01zulfi
561477fb1a web: use pgp keys for inbox
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-31 13:30:48 +05:00
01zulfi
543766d564 editor: support internal link titles for all available entities
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-31 13:17:53 +05:00
01zulfi
9789edad4c editor: show internal link's note title in hover popup
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-30 17:12:51 +05:00
Abdullah Atta
b46e02753a Merge pull request #9378 from streetwriters/desktop/handle-nn-protocol
Add handler for nn:// urls
2026-03-30 12:07:42 +05:00
Ammar Ahmed
5f74fcad0c Merge branch 'master' into beta 2026-03-30 09:44:11 +05:00
Ammar Ahmed
34b5005591 Merge branch 'master' into beta 2026-03-28 09:59:09 +05:00
Ammar Ahmed
2b0a1c6a78 Merge pull request #9552 from SahilAslam001/fet-close-all-tab
mobile: add close button in editor all tab bottom sheet  #6059
2026-03-28 09:12:15 +05:00
Ammar Ahmed
fcba5f9c91 mobile: fix editor stays open when opening notebook/tag/color link. 2026-03-27 11:41:47 +05:00
Ammar Ahmed
5909025976 mobile: add support for internal links 2026-03-27 11:38:51 +05:00
sahil
c2702efc7f mobile: change icon
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-03-26 15:55:00 +05:00
sahil
c96d13d416 mobile: fix icon and function name
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-03-26 14:28:27 +05:00
Ammar Ahmed
3d52a307ca Merge branch 'master' into beta 2026-03-26 12:20:46 +05:00
sahil
32c825463f mobile: add close button in editor all tab bottom sheet #6059
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-03-20 11:34:01 +05:00
Abdullah Atta
1172a8c67b web: fix events missed from desktop side on startup 2026-03-19 09:32:40 +05:00
Abdullah Atta
0191e5be00 desktop: wait for bridge to be ready before sending events 2026-03-19 09:32:07 +05:00
Abdullah Atta
7f367389bb web: support copying notebook/tag/color internal link 2026-03-19 09:24:39 +05:00
Abdullah Atta
e94c757d5b desktop: add handler for nn:// urls 2026-03-18 11:39:26 +05:00
Abdullah Atta
29e213ebe2 Merge branch 'master' into beta 2026-03-14 11:16:27 +05:00
Abdullah Atta
8eb7663f37 Merge branch 'master' into beta 2026-03-10 09:55:37 +05:00
90 changed files with 5322 additions and 4542 deletions

View File

@@ -96,6 +96,7 @@ module.exports = {
"node_modules/sodium-native/package.json"
],
afterPack: "./scripts/removeLocales.js",
protocols: [{ name: "Notesnook", schemes: ["nn"] }],
mac: {
bundleVersion: "240",
minimumSystemVersion: "10.12.0",
@@ -181,6 +182,7 @@ module.exports = {
icon: "assets/icons/app.icns",
description: "Your private note taking space",
executableName: linuxExecutableName,
mimeTypes: ["x-scheme-handler/nn"],
desktop: {
desktopActions: {
"new-note": {

View File

@@ -24,21 +24,43 @@ import TypedEventEmitter from "typed-emitter";
export type AppEvents = {
onCreateItem(name: "note" | "notebook" | "reminder"): void;
onOpenLink(url: string): void;
bridgeReady(): void;
};
const emitter = new EventEmitter();
const typedEmitter = emitter as TypedEventEmitter<AppEvents>;
let isBridgeReady = false;
const pendingEvents: { name: string; args: unknown[] }[] = [];
const _emitter = new EventEmitter();
const emitter = _emitter as TypedEventEmitter<AppEvents>;
const t = initTRPC.create();
export const bridgeRouter = t.router({
onCreateItem: createSubscription("onCreateItem")
onCreateItem: createSubscription("onCreateItem"),
onOpenLink: createSubscription("onOpenLink"),
ready: t.procedure.query(() => {
isBridgeReady = true;
if (pendingEvents.length > 0) {
console.log(
"Emitting pending events",
pendingEvents.map((e) => e.name)
);
pendingEvents.forEach((event) => {
emitter.emit(event.name as any, ...(event.args as any[]));
});
pendingEvents.length = 0;
}
return true;
})
});
export const bridge: AppEvents = new Proxy({} as AppEvents, {
get(_t, name) {
if (typeof name === "symbol") return;
return (...args: unknown[]) => {
emitter.emit(name, ...args);
if (!isBridgeReady) {
pendingEvents.push({ name, args });
return;
}
_emitter.emit(name, ...args);
};
}
});
@@ -49,9 +71,9 @@ function createSubscription<TName extends keyof AppEvents>(eventName: TName) {
const listener: AppEvents[TName] = (...args: any[]) => {
emit.next(args[0]);
};
typedEmitter.addListener(eventName, listener);
emitter.addListener(eventName, listener);
return () => {
typedEmitter.removeListener(eventName, listener);
emitter.removeListener(eventName, listener);
};
});
});

View File

@@ -55,6 +55,10 @@ setI18nGlobal(i18n);
const appHostnames = isDevelopment()
? ["localhost", "127.0.0.1"]
: ["app.notesnook.com"];
// Pending nn:// link to open once the window is ready (used on Windows/Linux
// when the app is launched via the nn:// protocol for the first time).
let pendingNNLink: string | undefined = findNNLink(process.argv);
// only run a single instance
if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) {
console.log("Another instance is already running!");
@@ -193,6 +197,11 @@ async function createWindow() {
setupTray();
setupJumplist();
});
if (pendingNNLink) {
bridge.onOpenLink(pendingNNLink);
pendingNNLink = undefined;
}
}
app.once("ready", async () => {
@@ -212,6 +221,8 @@ app.once("ready", async () => {
if (config.customDns) enableCustomDns();
else disableCustomDns();
if (!MAC_APP_STORE) app.setAsDefaultProtocolClient("nn");
if (!isDevelopment()) registerProtocol();
await createWindow();
configureAutoUpdater();
@@ -225,6 +236,12 @@ app.once("window-all-closed", () => {
app.on("second-instance", async (_ev, argv) => {
if (!globalThis.window) return;
const nnLink = findNNLink(argv);
if (nnLink) {
bridge.onOpenLink(nnLink);
bringToFront();
return;
}
const cliOptions = await parseArguments(argv);
if (cliOptions.note) bridge.onCreateItem("note");
if (cliOptions.notebook) bridge.onCreateItem("notebook");
@@ -232,12 +249,29 @@ app.on("second-instance", async (_ev, argv) => {
bringToFront();
});
// macOS opens URLs via this event. The app may or may not be fully loaded yet.
app.on("open-url", (event, url) => {
event.preventDefault();
if (!url.startsWith("nn://")) return;
if (globalThis.window) {
bridge.onOpenLink(url);
bringToFront();
} else {
// Window not ready yet — store for when createWindow finishes loading.
pendingNNLink = url;
}
});
app.on("activate", () => {
if (globalThis.window === null) {
createWindow();
}
});
function findNNLink(argv: string[]): string | undefined {
return argv.find((arg) => arg.startsWith("nn://"));
}
function createURL(options: CLIOptions, path = "/") {
const url = new URL(isDevelopment() ? "http://localhost:3000" : PROTOCOL_URL);
@@ -248,7 +282,7 @@ function createURL(options: CLIOptions, path = "/") {
else if (typeof options.note === "string")
url.hash = `/notes/${options.note}/edit`;
else if (typeof options.notebook === "string")
url.hash = `/notebooks/${options.notebook}`;
url.pathname = `/notebooks/${options.notebook}`;
return url;
}

View File

@@ -30,7 +30,7 @@ module.exports = {
testBinaryPath:
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
build:
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug -PreactNativeArchitectures=arm64-v8a && cd ..",
reversePorts: [8081]
},
"android.release": {

View File

@@ -155,6 +155,15 @@
<data android:scheme="notesnook" />
</intent-filter>
<intent-filter android:label="Notesnook">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nn" />
</intent-filter>
</activity>
<activity
android:name="com.facebook.react.devsupport.DevSettingsActivity"

View File

@@ -33,7 +33,7 @@ public class NotePreviewWidget extends AppWidgetProvider {
intent.putExtra(OpenNoteId, note.getId());
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
intent.setData(Uri.parse("https://app.notesnook.com/open_note?id=" + note.getId()));
intent.setData(Uri.parse("nn://note/" + note.getId()));
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);

View File

@@ -259,7 +259,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
return;
}
String uri = "https://app.notesnook.com/open_" + type + "?id=" + id;
String uri = "nn://" + type + "/" + id;
Intent intent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(uri));
intent.setPackage(mContext.getPackageName());

View File

@@ -31,6 +31,7 @@ import {
generateCryptoKeyFallback
} from "./encryption";
import { MMKV } from "./mmkv";
import OpenPGP from "react-native-fast-openpgp";
export class KV {
storage: MMKVInstance;
@@ -136,11 +137,30 @@ export const Storage: IStorage = {
clear(): Promise<void> {
return DefaultStorage.clear();
},
generateCryptoKeyPair() {
throw new Error("Not implemented");
async generatePGPKeyPair() {
const keys = await OpenPGP.generate({
name: "NN",
email: "NN@NN.NN"
});
return { publicKey: keys.publicKey, privateKey: keys.privateKey };
},
decryptAsymmetric() {
throw new Error("Not implemented");
async validatePGPKeyPair(keys) {
try {
const dummyData = JSON.stringify({
favorite: true,
title: "Hello world"
});
const encrypted = await OpenPGP.encrypt(dummyData, keys.publicKey);
const decrypted = await OpenPGP.decrypt(encrypted, keys.privateKey, "");
return decrypted === dummyData;
} catch (e) {
console.error("PGP key pair validation error:", e);
return false;
}
},
async decryptPGPMessage(privateKeyArmored, encryptedMessage) {
return await OpenPGP.decrypt(encryptedMessage, privateKeyArmored, "");
},
getAllKeys(): Promise<string[]> {
return DefaultStorage.getAllKeys();

View File

@@ -35,6 +35,7 @@ import { Button } from "../../ui/button";
import { IconButton } from "../../ui/icon-button";
import { Notice } from "../../ui/notice";
import Paragraph from "../../ui/typography/paragraph";
import SettingsService from "../../../services/settings";
export default function AttachImage({
response,
@@ -201,6 +202,17 @@ AttachImage.present = (response: ImageType[], context?: string) => {
| undefined
>((resolve) => {
let resolved = false;
const imageCompressionSetting =
SettingsService.getProperty("imageCompression");
if (imageCompressionSetting !== "ask-every-time") {
resolve({
compress: imageCompressionSetting === "enabled" ? true : false
});
return;
}
presentSheet({
context: context,
component: (ref, close, update) => (

View File

@@ -183,6 +183,7 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
>
<IconButton
name="close"
testID="paywall-close"
color={colors.primary.icon}
onPress={() => {
Navigation.navigate("FluidPanelsView", {});
@@ -1008,8 +1009,8 @@ const PricingPlanCard = ({
: "monthly"
}`
: pricingPlans.isGithubRelease
? (WebPlan?.period as string)
: (product?.productId as string)
? (WebPlan?.period as string)
: (product?.productId as string)
);
setStep(Steps.buy);
}}

View File

@@ -79,6 +79,8 @@ const COLUMN_BAR_ITEMS: ActionId[] = [
"rename-color",
"rename-tag",
"launcher-shortcut",
"copy-id",
"copy-link",
"restore",
"trash",
"delete"
@@ -173,15 +175,15 @@ export const Items = ({
DDS.isTab
? AppFontSize.xxl
: shouldShrink
? AppFontSize.xxl
: AppFontSize.lg
? AppFontSize.xxl
: AppFontSize.lg
}
color={
item.checked
? item.activeColor || colors.primary.accent
: item.id.match(/(delete|trash)/g)
? colors.error.icon
: colors.secondary.icon
? colors.error.icon
: colors.secondary.icon
}
/>
</Pressable>
@@ -212,8 +214,8 @@ export const Items = ({
text: item.checked
? item.activeColor || colors.primary.accent
: item.id === "delete" || item.id === "trash"
? colors.error.paragraph
: colors.primary.paragraph
? colors.error.paragraph
: colors.primary.paragraph
}}
testID={"icon-" + item.id}
onPress={item.onPress}
@@ -277,8 +279,8 @@ export const Items = ({
item.checked
? item.activeColor || colors.primary.accent
: item.id === "delete" || item.id === "trash"
? colors.error.icon
: colors.secondary.icon
? colors.error.icon
: colors.secondary.icon
}
/>

View File

@@ -0,0 +1,169 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useRef, useState } from "react";
import { View, ScrollView } from "react-native";
import { strings } from "@notesnook/intl";
import { db } from "../../../common/database";
import { Button } from "../../ui/button";
import Input from "../../ui/input";
import Paragraph from "../../ui/typography/paragraph";
import Heading from "../../ui/typography/heading";
import { DefaultAppStyles } from "../../../utils/styles";
import { AppFontSize } from "../../../utils/size";
import { ToastManager, presentSheet } from "../../../services/event-manager";
import { useThemeColors } from "@notesnook/theme";
import { Pressable } from "../../ui/pressable";
const getExpiryOptions = () => [
{ label: strings.expiryOneDay(), value: 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneWeek(), value: 7 * 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneMonth(), value: 30 * 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneYear(), value: 365 * 24 * 60 * 60 * 1000 },
{ label: strings.never(), value: -1 }
];
type AddApiKeySheetProps = {
close?: (ctx?: string | undefined) => void;
onAdd: () => void;
};
export default function AddApiKeySheet({ close, onAdd }: AddApiKeySheetProps) {
const { colors } = useThemeColors();
const keyNameRef = useRef<string>("");
const [selectedExpiry, setSelectedExpiry] = useState(
getExpiryOptions()[2].value
);
const [isCreating, setIsCreating] = useState(false);
const handleCreate = async () => {
try {
if (!keyNameRef.current || !keyNameRef.current.trim()) {
ToastManager.show({
message: strings.enterKeyName(),
type: "error"
});
return;
}
setIsCreating(true);
await db.inboxApiKeys.create(keyNameRef.current, selectedExpiry);
ToastManager.show({
message: strings.apiKeyCreatedSuccessfully(),
type: "success"
});
onAdd();
close?.();
} catch (error) {
const message = error instanceof Error ? error.message : "";
ToastManager.show({
message: strings.failedToCreateApiKey(message),
type: "error"
});
} finally {
setIsCreating(false);
}
};
return (
<ScrollView
contentContainerStyle={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
paddingBottom: DefaultAppStyles.GAP_VERTICAL * 2
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Heading size={AppFontSize.xl}>{strings.createApiKey()}</Heading>
</View>
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
<Paragraph size={AppFontSize.sm}>{strings.keyName()}</Paragraph>
<Input
placeholder={strings.exampleKeyName()}
onChangeText={(text) => {
keyNameRef.current = text;
}}
/>
</View>
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
<Paragraph size={AppFontSize.sm}>{strings.expiresIn()}</Paragraph>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: DefaultAppStyles.GAP_SMALL
}}
>
{getExpiryOptions().map((option) => (
<Pressable
key={option.label}
onPress={() => setSelectedExpiry(option.value)}
type={
selectedExpiry === option.value ? "selected" : "transparent"
}
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<Paragraph
size={AppFontSize.sm}
color={
selectedExpiry === option.value
? colors.selected.paragraph
: colors.primary.paragraph
}
>
{option.label}
</Paragraph>
</Pressable>
))}
</View>
</View>
<Button
title={isCreating ? strings.creating() : strings.create()}
type="accent"
width="100%"
loading={isCreating}
disabled={isCreating}
onPress={handleCreate}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
/>
</ScrollView>
);
}
AddApiKeySheet.present = (onAdd: () => void) => {
presentSheet({
component: (ref, close, update) => (
<AddApiKeySheet close={close} onAdd={onAdd} />
)
});
};

View File

@@ -33,11 +33,11 @@ import { editorController } from "../../../screens/editor/tiptap/utils";
import { eSendEvent, presentSheet } from "../../../services/event-manager";
import { eUnlockNote } from "../../../utils/events";
import { AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import { IconButton } from "../../ui/icon-button";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import { DefaultAppStyles } from "../../../utils/styles";
const TabItemComponent = (props: {
tab: TabItem;
@@ -225,14 +225,24 @@ export default function EditorTabs({
}}
>
<Heading size={AppFontSize.lg}>{strings.tabs()}</Heading>
<IconButton
onPress={() => {
useTabStore.getState().newTab();
close?.();
}}
name="plus"
color={colors.primary.accent}
/>
<View style={{ flexDirection: "row", gap: DefaultAppStyles.GAP_SMALL }}>
<IconButton
onPress={() => {
useTabStore.getState().clearAllTabs();
close?.();
}}
name="close-box-multiple-outline"
color={colors.primary.icon}
/>
<IconButton
onPress={() => {
useTabStore.getState().newTab();
close?.();
}}
name="plus"
color={colors.primary.accent}
/>
</View>
</View>
<FlatList
@@ -253,4 +263,4 @@ EditorTabs.present = () => {
presentSheet({
component: (ref, close, update) => <EditorTabs close={close} />
});
};
};

View File

@@ -17,7 +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 { hosts, Monograph, Note } from "@notesnook/core";
import { Note } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
@@ -53,15 +53,14 @@ async function fetchMonographData(noteId: string) {
const monograph = monographId
? await db.monographs.get(monographId)
: undefined;
const analyticsFeature = await isFeatureAvailable("monographAnalytics");
const analytics =
monographId && analyticsFeature
? await db.monographs.analytics(monographId)
: undefined;
const metadata = monographId
? await db.monographs.metadata(monographId)
: { publishUrl: "", analytics: { totalViews: 0 } };
return {
monograph,
monographId,
analytics
metadata
};
}
@@ -86,8 +85,9 @@ const PublishNoteSheet = ({
return fetchMonographData(note?.id);
}, []);
const monograph = monographData.result?.monograph;
const metadata = monographData.result?.metadata;
customTitle.current = monograph?.title || note.title || "";
const publishUrl = monograph && `${hosts.MONOGRAPH_HOST}/${monograph?.id}`;
const publishUrl = metadata?.publishUrl || monograph?.publishUrl || "";
const isPublished = db.monographs.monograph(note?.id);
useEffect(() => {
@@ -382,7 +382,7 @@ const PublishNoteSheet = ({
>
<Paragraph size={AppFontSize.sm}>{strings.views()}</Paragraph>
<Paragraph>
{monographData?.result?.analytics?.totalViews || 0}
{monographData?.result?.metadata?.totalViews || 0}
</Paragraph>
</View>
</View>
@@ -455,4 +455,4 @@ PublishNoteSheet.present = (note: Note) => {
});
};
export default PublishNoteSheet;
export default PublishNoteSheet;

View File

@@ -104,8 +104,8 @@ const ListBlockItem = ({
{item?.content.length > 200
? item?.content.slice(0, 200) + "..."
: !item.content || item.content.trim() === ""
? strings.linkNoteEmptyBlock()
: item.content}
? strings.linkNoteEmptyBlock()
: item.content}
</Paragraph>
<View
@@ -202,7 +202,7 @@ const ListNoteItem = ({
items: VirtualizedGrouping<Note> | undefined;
onSelect: (item: Note, blockId?: string) => void;
reference: Note;
internalLinks: MutableRefObject<InternalLink<"note">[] | undefined>;
internalLinks: MutableRefObject<InternalLink[] | undefined>;
listType: "linkedNotes" | "referencedIn";
}) => {
const { colors } = useThemeColors();
@@ -231,7 +231,10 @@ const ListNoteItem = ({
internalLinks.current = await db.notes.internalLinks(reference.id);
}
const noteLinks = internalLinks.current.filter(
(link) => link.id === item.id && link.params?.blockId
(link) =>
link.id === item.id &&
link.type === "note" &&
link.params?.blockId
);
if (noteLinks.length) {
@@ -239,7 +242,10 @@ const ListNoteItem = ({
setLinkedBlocks(
blocks.filter((block) =>
noteLinks.find((link) => block.id === link.params?.blockId)
noteLinks.find(
(link) =>
link.type === "note" && block.id === link.params?.blockId
)
)
);
}
@@ -404,7 +410,7 @@ export const ReferencesList = ({ item, close }: ReferencesListProps) => {
const { colors } = useThemeColors();
const [items, setItems] = useState<VirtualizedGrouping<Note>>();
const hasNoRelations = !items || items?.placeholders?.length === 0;
const internalLinks = useRef<InternalLink<"note">[]>([]);
const internalLinks = useRef<InternalLink[]>([]);
useEffect(() => {
db.relations?.[tab === 0 ? "from" : "to"]?.(

View File

@@ -48,7 +48,6 @@ export const Notice = ({
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingLeft: 5,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
flexDirection: "row",
backgroundColor: colors.secondary.background,
@@ -63,6 +62,9 @@ export const Notice = ({
size={isSmall ? AppFontSize.md + 2 : AppFontSize.xxl}
name={type}
color={type === "alert" ? colors.error.icon : colors.primary.accent}
style={{
marginTop: isSmall ? 3 : 5
}}
/>
<Paragraph
style={{

View File

@@ -108,6 +108,7 @@ export type ActionId =
| "attachments"
| "history"
| "copy-link"
| "copy-id"
| "reminders"
| "lock-unlock"
| "publish"
@@ -1011,20 +1012,6 @@ export const useActions = ({
icon: "history",
onPress: openHistory
},
{
id: "copy-link",
title: strings.copyLink(),
icon: "link",
onPress: () => {
Clipboard.setString(createInternalLink("note", item.id));
ToastManager.show({
heading: strings.linkCopied(),
message: createInternalLink("note", item.id),
context: "local",
type: "success"
});
}
},
{
id: "reminders",
title: strings.dataTypesPluralCamelCase.reminder(),
@@ -1280,5 +1267,45 @@ export const useActions = ({
});
}
if (
item.type === "tag" ||
item.type === "note" ||
item.type === "notebook" ||
item.type === "color"
) {
actions.push({
id: "copy-link",
title: strings.copyLink(),
icon: "link",
onPress: () => {
const link = createInternalLink(item.type, item.id);
Clipboard.setString(link);
ToastManager.show({
heading: strings.linkCopied(),
message: link,
context: "local",
type: "success"
});
}
});
}
if (item.type === "notebook" || item.type === "tag") {
actions.push({
id: "copy-id",
title: strings.copyId(),
icon: "identifier",
onPress: () => {
Clipboard.setString(item.id);
ToastManager.show({
heading: strings.idCopied(),
message: item.id,
context: "local",
type: "success"
});
}
});
}
return actions;
};

View File

@@ -25,7 +25,9 @@ import {
SYNC_CHECK_IDS,
SubscriptionPlan,
SyncStatusEvent,
User
User,
isInternalLink,
parseInternalLink
} from "@notesnook/core";
import { strings } from "@notesnook/intl";
import notifee from "@notifee/react-native";
@@ -169,6 +171,8 @@ const onAppOpenedFromURL = async (event: {
}) => {
const url = event.url;
const parsedLink = isInternalLink(url) ? parseInternalLink(url) : undefined;
try {
if (url.startsWith("https://app.notesnook.com/account/verified")) {
await onUserEmailVerified();
@@ -178,8 +182,12 @@ const onAppOpenedFromURL = async (event: {
eSendEvent(eOnLoadNote, { newNote: true });
fluidTabsRef.current?.goToPage("editor", false);
return;
} else if (url.startsWith("https://app.notesnook.com/open_note?")) {
const id = new URL(url).searchParams.get("id");
} else if (
parsedLink?.type === "note" ||
url.startsWith("https://app.notesnook.com/open_note?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const note = await db.notes.note(id);
if (note) {
@@ -191,13 +199,15 @@ const onAppOpenedFromURL = async (event: {
}
}
} else if (
url.startsWith("https://app.notesnook.com/open_notebook?") &&
(parsedLink?.type === "notebook" ||
url.startsWith("https://app.notesnook.com/open_notebook?")) &&
!event.isInitialUrl
) {
const id = new URL(url).searchParams.get("id");
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const notebook = await db.notebooks.notebook(id);
if (notebook) {
fluidTabsRef.current?.goToPage("home");
Navigation.navigate("Notebook", {
id: notebook.id,
canGoBack: true,
@@ -206,13 +216,15 @@ const onAppOpenedFromURL = async (event: {
}
}
} else if (
url.startsWith("https://app.notesnook.com/open_tag?") &&
(parsedLink?.type === "tag" ||
url.startsWith("https://app.notesnook.com/open_tag?")) &&
!event.isInitialUrl
) {
const id = new URL(url).searchParams.get("id");
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const tag = await db.tags.tag(id);
if (tag) {
fluidTabsRef.current?.goToPage("home");
Navigation.navigate("TaggedNotes", {
type: "tag",
id: tag.id,
@@ -222,13 +234,15 @@ const onAppOpenedFromURL = async (event: {
}
}
} else if (
url.startsWith("https://app.notesnook.com/open_color?") &&
(parsedLink?.type === "color" ||
url.startsWith("https://app.notesnook.com/open_color?")) &&
!event.isInitialUrl
) {
const id = new URL(url).searchParams.get("id");
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const color = await db.colors.color(id);
if (color) {
fluidTabsRef.current?.goToPage("home");
Navigation.navigate("ColoredNotes", {
type: "color",
id: color.id,

View File

@@ -31,6 +31,7 @@ import { useSettingStore } from "../stores/use-setting-store";
import { rootNavigatorRef } from "../utils/global-refs";
import Navigation from "../services/navigation";
import { isFeatureAvailable } from "@notesnook/common";
import { isInternalLink, parseInternalLink } from "@notesnook/core";
const RootStack = createNativeStackNavigator();
const AppStack = createNativeStackNavigator();
@@ -63,10 +64,17 @@ const AppNavigation = React.memo(
React.useEffect(() => {
if (!home) {
if (useSettingStore.getState().initialUrl) {
const url = useSettingStore.getState().initialUrl;
if (url?.startsWith("https://app.notesnook.com/open_notebook?")) {
const id = new URL(url).searchParams.get("id");
const url = useSettingStore.getState().initialUrl;
if (url) {
const parsedLink = isInternalLink(url)
? parseInternalLink(url)
: undefined;
if (
parsedLink?.type === "notebook" ||
url?.startsWith("https://app.notesnook.com/open_notebook?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
setHome({
name: "Notebook",
@@ -76,8 +84,11 @@ const AppNavigation = React.memo(
});
return;
}
} else if (url?.startsWith("https://app.notesnook.com/open_tag?")) {
const id = new URL(url).searchParams.get("id");
} else if (
parsedLink?.type === "tag" ||
url?.startsWith("https://app.notesnook.com/open_tag?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
setHome({
name: "TaggedNotes",
@@ -88,8 +99,11 @@ const AppNavigation = React.memo(
});
return;
}
} else if (url?.startsWith("https://app.notesnook.com/open_color?")) {
const id = new URL(url).searchParams.get("id");
} else if (
parsedLink?.type === "color" ||
url?.startsWith("https://app.notesnook.com/open_color?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
setHome({
name: "ColoredNotes",

View File

@@ -19,7 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import Sodium from "@ammarahmed/react-native-sodium";
import { isFeatureAvailable } from "@notesnook/common";
import { isImage } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import {
DocumentPickerOptions,

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-case-declarations */
/* eslint-disable @typescript-eslint/no-var-requires */
import { isFeatureAvailable, useAreFeaturesAvailable } from "@notesnook/common";
import { ItemReference } from "@notesnook/core";
import { ItemReference, parseInternalLink } from "@notesnook/core";
import type { Attachment } from "@notesnook/editor";
import { EditorEvents } from "@notesnook/editor-mobile/src/utils/editor-events";
import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events";
@@ -471,9 +471,8 @@ export const useEditorEvents = (
relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(),
onAdd: async () => {
const reminderFeature = await isFeatureAvailable(
"activeReminders"
);
const reminderFeature =
await isFeatureAvailable("activeReminders");
if (!reminderFeature.isAllowed) {
ToastManager.show({
type: "info",
@@ -533,7 +532,59 @@ export const useEditorEvents = (
downloadAttachment((editorMessage.value as Attachment)?.hash, true);
break;
}
case EditorEvents.getLinkData: {
const url = (editorMessage.value as any)?.url as string;
const link = parseInternalLink(url);
if (!link) return;
switch (link.type) {
case "note":
case "notebook":
case "tag": {
const table =
link.type === "note"
? "notes"
: link.type === "notebook"
? "notebooks"
: "tags";
const item = await db
.sql()
.selectFrom(table)
.where("id", "=", link.id)
.select("title")
.executeTakeFirst();
editor.postMessage(NativeEvents.resolve, {
resolverId: editorMessage.resolverId,
data: {
type: link.type,
title: item?.title || ""
}
});
break;
}
case "color": {
const color = await db
.sql()
.selectFrom("colors")
.where("id", "=", link.id)
.select(["title", "colorCode"])
.executeTakeFirst();
editor.postMessage(NativeEvents.resolve, {
resolverId: editorMessage.resolverId,
data: {
type: "color",
title: color?.title || "",
metadata: {
colorCode: color?.colorCode || ""
}
}
});
}
}
break;
}
case EditorEvents.getAttachmentData: {
const data = (editorMessage.value as any)?.attachment as Attachment;

View File

@@ -187,6 +187,7 @@ export type TabStore = {
focusEmptyTab: () => void;
getCurrentNoteId: () => string | undefined;
getTab: (tabId: string) => TabItem | undefined;
clearAllTabs: () => void;
newTabSession: (
id: string,
options?: Omit<Partial<TabSessionItem>, "id">
@@ -255,7 +256,7 @@ export const useTabStore = create<TabStore, any>(
const sessionId =
oldSessionId &&
tabSessionHistory.currentSessionId(tabId) === oldSessionId
tabSessionHistory.currentSessionId(tabId) === oldSessionId
? oldSessionId
: tabSessionHistory.add(tabId, oldSessionId);
@@ -386,7 +387,7 @@ export const useTabStore = create<TabStore, any>(
focusPreviewTab: (
noteId: string,
options: Omit<Partial<TabItem>, "id" | "noteId">
) => {},
) => { },
removeTab: (id: string) => {
const index = get().tabs.findIndex((t) => t.id === id);
@@ -482,6 +483,25 @@ export const useTabStore = create<TabStore, any>(
},
getTab: (tabId) => {
return get().tabs.find((t) => t.id === tabId);
},
clearAllTabs: () => {
const tabs = get().tabs;
tabs.forEach((tab) => {
const tabSessions = tabSessionHistory.getTabHistory(tab.id);
tabSessions.back.forEach((id) => TabSessionStorage.remove(id));
tabSessions.forward.forEach((id) => TabSessionStorage.remove(id));
tabSessionHistory.clearStackForTab(tab.id);
});
const id = getId();
set({
tabs: [{ id: id }],
currentTab: id
});
history.history = [id];
get().newTabSession(id);
get().focusTab(id);
syncTabs();
}
}),
{

View File

@@ -171,7 +171,7 @@ export function clearAppState() {
export async function openInternalLink(url: string) {
const data = parseInternalLink(url);
if (!data?.id) return false;
if (!data?.id || data.type !== "note") return false;
if (
data.id ===
useTabStore.getState().getNoteIdForTab(useTabStore.getState().currentTab!)

View File

@@ -36,6 +36,7 @@ import {
WeekFormatPicker,
FontPicker,
HomePicker,
ImageCompressionPicker,
SidebarTabPicker,
TimeFormatPicker,
TrashIntervalPicker
@@ -46,6 +47,7 @@ import SoundPicker from "./sound-picker";
import ThemeSelector from "./theme-selector";
import { TitleFormat } from "./title-format";
import { NotesnookCircle } from "./notesnook-circle";
import { ManageInboxKeys, InboxKeysList } from "./manage-inbox-keys";
export const components: { [name: string]: ReactElement } = {
homeselector: <HomePicker />,
@@ -61,6 +63,7 @@ export const components: { [name: string]: ReactElement } = {
"time-format-selector": <TimeFormatPicker />,
"day-format-selector": <DayFormatPicker />,
"week-format-selector": <WeekFormatPicker />,
"image-compression-picker": <ImageCompressionPicker />,
"theme-selector": <ThemeSelector />,
"applock-timer": <ApplockTimerPicker />,
autobackupsattachments: <BackupWithAttachmentsReminderPicker />,
@@ -75,5 +78,7 @@ export const components: { [name: string]: ReactElement } = {
"sidebar-tab-selector": <SidebarTabPicker />,
"change-password": <ChangePassword />,
"change-email": <ChangeEmail />,
"notesnook-circle": <NotesnookCircle />
"notesnook-circle": <NotesnookCircle />,
"manage-inbox-keys": <ManageInboxKeys />,
"inbox-keys": <InboxKeysList />
};

View File

@@ -0,0 +1,474 @@
import { usePromise } from "@notesnook/common";
import { db } from "../../common/database";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { Button } from "../../components/ui/button";
import { strings } from "@notesnook/intl";
import Input from "../../components/ui/input";
import { useEffect, useRef, useState } from "react";
import { SerializedKeyPair } from "@notesnook/crypto";
import Paragraph from "../../components/ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";
import { ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { Storage } from "../../common/database/storage";
import { Notice } from "../../components/ui/notice";
import { presentDialog } from "../../components/dialog/functions";
import { useSettingStore } from "../../stores/use-setting-store";
import { InboxApiKey } from "@notesnook/core";
import { IconButton } from "../../components/ui/icon-button";
import Clipboard from "@react-native-clipboard/clipboard";
import { useThemeColors } from "@notesnook/theme";
import dayjs from "dayjs";
import Heading from "../../components/ui/typography/heading";
import { AppFontSize } from "../../utils/size";
import AddApiKeySheet from "../../components/sheets/add-api-key";
const ManageInboxKeys = () => {
const keys = usePromise(() => db.user.getInboxKeys());
const keysEdited = useRef<SerializedKeyPair>(undefined);
if (keys.status === "fulfilled" && keys.value && !keysEdited.current) {
keysEdited.current = keys.value;
}
return (
<ScrollView
contentContainerStyle={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<Notice type="alert" text={strings.changingInboxPgpKeysNotice()} />
<Paragraph>{strings.publicKey()}</Paragraph>
<Input
defaultValue={keysEdited.current?.publicKey}
multiline
onChangeText={(value) => {
if (keysEdited.current) {
keysEdited.current.publicKey = value;
}
}}
style={{
height: 150
}}
wrapperStyle={{
height: 150
}}
/>
<Paragraph>{strings.privateKey()}</Paragraph>
<Input
defaultValue={keysEdited.current?.privateKey}
multiline
onChangeText={(value) => {
if (keysEdited.current) {
keysEdited.current.privateKey = value;
}
}}
style={{
height: 150
}}
wrapperStyle={{
height: 150
}}
/>
<Button
title={strings.save()}
type="accent"
width={"100%"}
onPress={async () => {
try {
if (keysEdited.current) {
const valid = await Storage.validatePGPKeyPair(
keysEdited.current
);
if (!valid) {
ToastManager.show({
message: strings.invalidPgpKeyPair(),
type: "error"
});
return;
}
presentDialog({
title: strings.areYouSure(),
paragraph: strings.changingInboxPgpKeysNotice(),
positiveText: strings.yes(),
negativeText: strings.no(),
positivePress: async () => {
db.user?.saveInboxKeys(keysEdited.current!);
ToastManager.show({
message: strings.inboxKeysSaved(),
type: "success"
});
Navigation.goBack();
return true;
}
});
}
} catch (e) {
ToastManager.error(e as Error);
}
}}
/>
</ScrollView>
);
};
const InboxKeysList = () => {
const inboxEnabled = useSettingStore((state) => state.inboxEnabled);
const apiKeysPromise = usePromise(
() => db.inboxApiKeys.get(),
[inboxEnabled]
);
const { colors } = useThemeColors();
if (apiKeysPromise.status === "pending") {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
>
<ActivityIndicator size="small" color={colors.primary.accent} />
<Paragraph color={colors.secondary.paragraph}>
{strings.loadingApiKeys()}
</Paragraph>
</View>
);
}
if (apiKeysPromise.status === "rejected") {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
>
<Paragraph color={colors.error.paragraph}>
{strings.failedToLoadApiKeys()}
</Paragraph>
<Button
title={strings.retry()}
type="accent"
onPress={() => apiKeysPromise.refresh()}
/>
</View>
);
}
const apiKeys = apiKeysPromise.value || [];
return (
<ScrollView
contentContainerStyle={{
gap: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP,
paddingBottom: 50
}}
>
{apiKeys.length === 0 ? (
<View
style={{
padding: DefaultAppStyles.GAP * 2,
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.secondary.border,
borderRadius: 5,
backgroundColor: colors.secondary.background,
alignItems: "center"
}}
>
<Paragraph color={colors.secondary.paragraph}>
{strings.createFirstApiKey()}
</Paragraph>
</View>
) : (
<View style={{ gap: 0 }}>
{apiKeys.map((key, i) => (
<ApiKeyItem
key={key.key}
apiKey={key}
onRevoke={() => apiKeysPromise.refresh()}
isAtEnd={i === apiKeys.length - 1}
/>
))}
<Button
title={strings.createKey()}
type="accent"
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
width: "100%"
}}
onPress={() => {
if (apiKeys.length >= 10) {
presentDialog({
title: strings.apiKeysLimitReached(),
paragraph: strings.apiKeysLimitReachedMessage(),
positiveText: strings.ok()
});
} else {
AddApiKeySheet.present(() => apiKeysPromise.refresh());
}
}}
/>
</View>
)}
</ScrollView>
);
};
const VIEW_KEY_TIMEOUT = 15;
type ApiKeyItemProps = {
apiKey: InboxApiKey;
onRevoke: () => void;
isAtEnd: boolean;
};
function ApiKeyItem({ apiKey, onRevoke, isAtEnd }: ApiKeyItemProps) {
const { colors } = useThemeColors();
const [copied, setCopied] = useState(false);
const [viewing, setViewing] = useState(false);
const [isRevoking, setIsRevoking] = useState(false);
const [secondsLeft, setSecondsLeft] = useState(VIEW_KEY_TIMEOUT);
async function viewKey() {
presentDialog({
title: strings.authenticateToViewApiKey(),
paragraph: strings.enterPasswordToViewApiKey(),
positiveText: strings.authenticate(),
negativeText: strings.cancel(),
input: true,
secureTextEntry: true,
inputPlaceholder: strings.accountPassword(),
positivePress: async (value) => {
try {
const verified = await db.user.verifyPassword(value);
if (!verified) {
ToastManager.show({
message: strings.invalidPassword(),
type: "error"
});
return false;
}
setViewing(true);
return true;
} catch (error) {
ToastManager.error(error as Error);
return false;
}
}
});
}
async function copyToClipboard() {
if (!viewing) return;
try {
Clipboard.setString(apiKey.key);
setCopied(true);
ToastManager.show({
message: strings.apiKeyCopiedToClipboard(),
type: "success"
});
} catch (error) {
ToastManager.show({
message: strings.failedToCopyToClipboard(),
type: "error"
});
}
}
useEffect(() => {
if (copied) {
const timer = setTimeout(() => {
setCopied(false);
}, 1000);
return () => clearTimeout(timer);
}
}, [copied]);
useEffect(() => {
if (viewing) {
setSecondsLeft(VIEW_KEY_TIMEOUT);
const interval = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
setViewing(false);
return VIEW_KEY_TIMEOUT;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [viewing]);
const isApiKeyExpired = Date.now() > apiKey.expiryDate;
return (
<View
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
borderBottomWidth: isAtEnd ? 0 : 1,
borderBottomColor: colors.secondary.border
}}
>
<View
style={{
flexDirection: "column",
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<Heading size={AppFontSize.md}>{apiKey.name}</Heading>
{isApiKeyExpired && (
<View
style={{
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: colors.error.background,
borderRadius: 5
}}
>
<Paragraph
color={colors.static.white}
size={AppFontSize.xxs}
style={{ fontWeight: "bold" }}
>
EXPIRED
</Paragraph>
</View>
)}
</View>
<View style={{ gap: 4 }}>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{apiKey.lastUsedAt
? `${strings.lastUsedOn()} ${dayjs(apiKey.lastUsedAt).format("MMM DD, YYYY")}`
: strings.neverUsed()}
</Paragraph>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{strings.createdOn()}{" "}
{dayjs(apiKey.dateCreated).format("MMM DD, YYYY")}
</Paragraph>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{apiKey.expiryDate === -1
? strings.neverExpires()
: `${isApiKeyExpired ? strings.expired() : strings.expiresOn()} ${dayjs(apiKey.expiryDate).format("MMM DD, YYYY")}`}
</Paragraph>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<Input
editable={false}
value={
viewing
? apiKey.key
: `${apiKey.key.slice(0, 10)}${"*".repeat(
apiKey.key.length - 10
)}`
}
style={{
flex: 1,
fontFamily: "monospace",
fontSize: AppFontSize.xs
}}
wrapperStyle={{
flex: 1
}}
/>
{!viewing && (
<IconButton
name="eye-off-outline"
color={colors.primary.icon}
onPress={() => viewKey()}
/>
)}
{viewing && (
<>
<Paragraph
style={{
fontFamily: "monospace",
minWidth: 35,
textAlign: "center"
}}
color={colors.primary.accent}
>
{secondsLeft}s
</Paragraph>
<IconButton
name={copied ? "check" : "content-copy"}
color={colors.primary.icon}
onPress={() => copyToClipboard()}
/>
</>
)}
<IconButton
name="delete-outline"
color={colors.error.icon}
disabled={isRevoking}
onPress={async () => {
presentDialog({
title: strings.revokeInboxApiKey(apiKey.name),
paragraph: strings.revokeApiKeyConfirmation(apiKey.name),
positiveText: strings.revoke(),
negativeText: strings.cancel(),
positiveType: "error",
positivePress: async () => {
try {
setIsRevoking(true);
await db.inboxApiKeys.revoke(apiKey.key);
onRevoke();
ToastManager.show({
message: strings.apiKeyRevoked(),
type: "success"
});
return true;
} catch (error) {
ToastManager.show({
message: strings.failedToRevokeApiKey(),
type: "error"
});
return false;
} finally {
setIsRevoking(false);
}
}
});
}}
/>
</View>
</View>
</View>
);
}
export { ManageInboxKeys, InboxKeysList };

View File

@@ -32,11 +32,11 @@ import { sleep } from "../../../utils/time";
import { verifyUser } from "../functions";
import { DefaultAppStyles } from "../../../utils/styles";
interface PickerOptions<T> {
getValue: () => T;
interface PickerOptions<T, B = any> {
getValue: () => B;
updateValue: (item: T) => Promise<void>;
formatValue: (item: T) => any;
compareValue: (current: T, item: T) => boolean;
compareValue: (current: B, item: T) => boolean;
getItemKey: (item: T) => string;
options: T[];
isFeatureAvailable: () => Promise<boolean>;
@@ -170,7 +170,7 @@ export function SettingsPicker<T>({
);
}
export function createSettingsPicker<T>(props: PickerOptions<T>) {
export function createSettingsPicker<T, B>(props: PickerOptions<T, B>) {
const Selector = () => {
return <SettingsPicker {...props} />;
};

View File

@@ -17,14 +17,21 @@ 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 { DATE_FORMATS, TIME_FORMATS } from "@notesnook/core";
import {
DATE_FORMATS,
DayFormat,
TIME_FORMATS,
TimeFormat,
TrashCleanupInterval,
WeekFormat
} from "@notesnook/core";
import { getFontById, getFonts } from "@notesnook/editor/dist/cjs/utils/font";
import dayjs from "dayjs";
import { createSettingsPicker } from ".";
import { db } from "../../../common/database";
import { ToastManager } from "../../../services/event-manager";
import SettingsService from "../../../services/settings";
import { useSettingStore } from "../../../stores/use-setting-store";
import { Settings, useSettingStore } from "../../../stores/use-setting-store";
import { useUserStore } from "../../../stores/use-user-store";
import { MenuItemsList } from "../../../utils/menu-items";
import { verifyUserWithApplock } from "../functions";
@@ -44,26 +51,29 @@ const WeekFormatNames = {
Mon: "Monday"
};
export const FontPicker = createSettingsPicker({
export const FontPicker = createSettingsPicker<
ReturnType<typeof getFonts>[0],
Settings["defaultFontFamily"]
>({
getValue: () => useSettingStore.getState().settings.defaultFontFamily,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({
defaultFontFamily: item.id
});
},
formatValue: (item) => {
return getFontById(typeof item === "object" ? item.id : item).title;
return getFontById(typeof item === "object" ? item.id : item)?.title;
},
getItemKey: (item) => item.id,
options: getFonts(),
compareValue: (current, item) => current === item.id,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const HomePicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.homepage,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ homepage: item.title });
ToastManager.show({
heading: strings.homePageChangedTo(item.title),
@@ -72,18 +82,22 @@ export const HomePicker = createSettingsPicker({
});
},
formatValue: (item) => {
return strings.routes[typeof item === "object" ? item.title : item]?.();
return strings.routes[
(typeof item === "object"
? item.title
: item) as keyof typeof strings.routes
]?.();
},
getItemKey: (item) => item.title,
options: MenuItemsList.slice(0, MenuItemsList.length - 1),
compareValue: (current, item) => current === item.title,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const SidebarTabPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.defaultSidebarTab,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ defaultSidebarTab: item });
},
formatValue: (item) => {
@@ -94,7 +108,7 @@ export const SidebarTabPicker = createSettingsPicker({
];
return SidebarTabs[item];
},
getItemKey: (item) => item,
getItemKey: (item) => `side-bar-tab-picker-${item}`,
options: [0, 1, 2],
compareValue: (current, item) => current === item,
isFeatureAvailable: async () => {
@@ -111,12 +125,12 @@ export const SidebarTabPicker = createSettingsPicker({
}
return result.isAllowed;
},
isOptionAvailable: () => true
isOptionAvailable: async () => true
});
export const TrashIntervalPicker = createSettingsPicker({
getValue: () => db.settings.getTrashCleanupInterval(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setTrashCleanupInterval(item);
},
formatValue: (item) => {
@@ -127,9 +141,9 @@ export const TrashIntervalPicker = createSettingsPicker({
: strings.days(item);
},
getItemKey: (item) => item.toString(),
options: [-1, 1, 7, 30, 365],
options: [-1, 1, 7, 30, 365] as TrashCleanupInterval[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isFeatureAvailable: async () => true,
isOptionAvailable: async () => {
const disableTrashFeature = await isFeatureAvailable("disableTrashCleanup");
if (!disableTrashFeature.isAllowed) {
@@ -148,7 +162,7 @@ export const TrashIntervalPicker = createSettingsPicker({
export const DateFormatPicker = createSettingsPicker({
getValue: () => db.settings.getDateFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setDateFormat(item);
useSettingStore.setState({
dateFormat: item
@@ -160,13 +174,13 @@ export const DateFormatPicker = createSettingsPicker({
getItemKey: (item) => item,
options: DATE_FORMATS,
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const DayFormatPicker = createSettingsPicker({
getValue: () => db.settings.getDayFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setDayFormat(item);
useSettingStore.setState({
dayFormat: item
@@ -176,15 +190,15 @@ export const DayFormatPicker = createSettingsPicker({
return `${strings.dayFormat()} (${dayjs().format(DayFormatFormats[item])})`;
},
getItemKey: (item) => item,
options: DAY_FORMATS,
options: DAY_FORMATS as DayFormat[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const WeekFormatPicker = createSettingsPicker({
getValue: () => db.settings.getWeekFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setWeekFormat(item);
useSettingStore.setState({
weekFormat: item
@@ -194,10 +208,10 @@ export const WeekFormatPicker = createSettingsPicker({
return `${WeekFormatNames[item]}`;
},
getItemKey: (item) => item,
options: WEEK_FORMATS,
options: WEEK_FORMATS as WeekFormat[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
const TimeFormats = {
@@ -207,7 +221,7 @@ const TimeFormats = {
export const TimeFormatPicker = createSettingsPicker({
getValue: () => db.settings.getTimeFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setTimeFormat(item);
useSettingStore.setState({
timeFormat: item
@@ -217,15 +231,18 @@ export const TimeFormatPicker = createSettingsPicker({
return `${strings[item]()} (${dayjs().format(TimeFormats[item])})`;
},
getItemKey: (item) => item,
options: TIME_FORMATS,
options: TIME_FORMATS as TimeFormat[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const BackupReminderPicker = createSettingsPicker({
export const BackupReminderPicker = createSettingsPicker<
Settings["reminder"],
Settings["reminder"]
>({
getValue: () => useSettingStore.getState().settings.reminder,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ reminder: item });
},
formatValue: (item) => {
@@ -237,39 +254,40 @@ export const BackupReminderPicker = createSettingsPicker({
requiresVerification: () => {
return (
!useSettingStore.getState().settings.encryptedBackup &&
useUserStore.getState().user
!!useUserStore.getState().user
);
},
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const BackupWithAttachmentsReminderPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.fullBackupReminder,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ fullBackupReminder: item });
},
formatValue: (item) => {
//@ts-ignore
return item === "useroff" || item === "off" || item === "never"
? "Off"
: item.slice(0, 1).toUpperCase() + item.slice(1);
},
getItemKey: (item) => item,
options: ["never", "weekly", "monthly"],
options: ["never", "weekly", "monthly"] as Settings["fullBackupReminder"][],
compareValue: (current, item) => current === item,
requiresVerification: () => {
return (
!useSettingStore.getState().settings.encryptedBackup &&
useUserStore.getState().user
!!useUserStore.getState().user
);
},
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const ApplockTimerPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.appLockTimer,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ appLockTimer: item });
},
formatValue: (item) => {
@@ -287,6 +305,45 @@ export const ApplockTimerPicker = createSettingsPicker({
onVerify: () => {
return verifyUserWithApplock();
},
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const ImageCompressionPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.imageCompression,
updateValue: async (item) => {
SettingsService.set({ imageCompression: item });
},
formatValue: (item) => {
return item === "ask-every-time"
? strings.askEveryTime()
: item === "enabled"
? strings.enableRecommended()
: strings.disable();
},
getItemKey: (item) => item,
options: [
"ask-every-time",
"enabled",
"disabled"
] as Settings["imageCompression"][],
compareValue: (current, item) => current === item,
isFeatureAvailable: async () => true,
isOptionAvailable: async (item) => {
const feature = await isFeatureAvailable("fullQualityImages");
if (!feature.isAllowed && item === "enabled") {
ToastManager.show({
message: feature.error,
type: "info",
actionText: strings.upgrade(),
func: () => {
PaywallSheet.present(feature);
}
});
return false;
}
return true;
}
});

View File

@@ -233,19 +233,22 @@ const _SectionItem = ({ item }: { item: SettingSection }) => {
<View
style={{
flexShrink: 1,
paddingRight: item.type === "switch" ? 10 : 0
paddingRight: item.type === "switch" ? 10 : 0,
flex: item.type === "component" ? 1 : 0
}}
>
<Heading
color={
item.type === "danger"
? colors.error.paragraph
: colors.primary.heading
}
size={AppFontSize.sm}
>
{typeof item.name === "function" ? item.name(current) : item.name}
</Heading>
{item.name ? (
<Heading
color={
item.type === "danger"
? colors.error.paragraph
: colors.primary.heading
}
size={AppFontSize.sm}
>
{typeof item.name === "function" ? item.name(current) : item.name}
</Heading>
) : null}
{!!item.description && (
<Paragraph

View File

@@ -81,6 +81,7 @@ import { MMKV } from "../../common/database/mmkv";
import { resetTabStore } from "../editor/tiptap/use-tab-store";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
import { useSettingStore } from "../../stores/use-setting-store";
export const settingsGroups: SettingSection[] = [
{
@@ -686,6 +687,63 @@ export const settingsGroups: SettingSection[] = [
type: "screen",
description: strings.notesnookCircleDesc(),
component: "notesnook-circle"
},
{
id: "inbox-api",
name: strings.inboxAPI(),
icon: "inbox",
type: "screen",
description: strings.inboxAPIDesc(),
sections: [
{
id: "toggle-inbox-api",
name: strings.enableInboxAPI(),
description: strings.enableInboxAPIDesc(),
type: "switch",
useHook: () => useSettingStore((state) => state.inboxEnabled),
getter: (current) => current,
modifer: async (current) => {
if (current) {
presentDialog({
title: strings.disableInboxAPI(),
paragraph: strings.disableInboxAPIDesc(),
positiveText: strings.disable(),
positivePress: async () => {
await db.user.discardInboxKeys();
useSettingStore.setState({
inboxEnabled: false
});
return true;
}
});
return;
}
try {
await db.user.getInboxKeys();
useSettingStore.setState({
inboxEnabled: true
});
} catch (e) {
console.log(e);
}
}
},
{
id: "manage-inbox-keys",
name: strings.manageInboxKeys(),
description: strings.manageInboxKeysDesc(),
type: "screen",
component: "manage-inbox-keys"
},
{
id: "inbox-keys",
name: strings.viewAPIKeys(),
description: strings.viewAPIKeysDesc(),
type: "screen",
component: "inbox-keys"
}
]
}
]
},
@@ -816,6 +874,14 @@ export const settingsGroups: SettingSection[] = [
description: strings.autoUpdateCheckDesc(),
property: "checkForUpdates",
icon: "update"
},
{
id: "image-compression",
type: "component",
name: strings.imageCompression(),
description: strings.imageCompressionDesc(),
component: "image-compression-picker",
icon: "image-area"
}
]
},

View File

@@ -102,6 +102,7 @@ export type Settings = {
defaultSidebarTab: number;
checkForUpdates?: boolean;
defaultLineHeight: number;
imageCompression: "ask-every-time" | "enabled" | "disabled";
};
type DimensionsType = {
@@ -143,6 +144,7 @@ export interface SettingStore {
isOldAppLock: () => boolean;
initialUrl: string | null;
refresh: () => void;
inboxEnabled: boolean;
}
const { width, height } = Dimensions.get("window");
@@ -206,7 +208,8 @@ export const defaultSettings: SettingStore["settings"] = {
fullBackupReminder: "never",
lastFullBackupDate: 0,
checkForUpdates: true,
defaultLineHeight: EDITOR_LINE_HEIGHT.DEFAULT
defaultLineHeight: EDITOR_LINE_HEIGHT.DEFAULT,
imageCompression: "ask-every-time"
};
export const useSettingStore = create<SettingStore>((set, get) => ({
@@ -248,12 +251,14 @@ export const useSettingStore = create<SettingStore>((set, get) => ({
? initialWindowMetrics.insets
: { top: 0, right: 0, left: 0, bottom: 0 },
initialUrl: null,
refresh: () => {
refresh: async () => {
set({
dayFormat: db.settings.getDayFormat(),
timeFormat: db.settings.getTimeFormat(),
dateFormat: db.settings?.getTimeFormat(),
weekFormat: db.settings.getWeekFormat()
weekFormat: db.settings.getWeekFormat(),
inboxEnabled: await db.user.hasInboxKeys()
});
}
},
inboxEnabled: false
}));

View File

@@ -56,24 +56,26 @@ const USER = {
async function login() {
await TestBuilder.create()
.waitAndTapByText("Login to encrypt and sync notes")
.typeTextById("input.email", USER.login.email!)
.replaceTextById("input.email", USER.login.email!)
.tapReturnKeyById("input.email")
.wait(3000)
.typeTextById("input.totp", authenticator.generate(USER.login.totpSecret!))
.replaceTextById(
"input.totp",
authenticator.generate(USER.login.totpSecret!)
)
.waitAndTapByText("Next")
.wait(3000)
.typeTextById("input.password", USER.login.password!)
.replaceTextById("input.password", USER.login.password!)
.tapReturnKeyById("input.password")
.wait(4000)
.tapById("paywall-close")
.wait(3000)
.isVisibleById("Search in Notes")
.run();
}
describe("AUTH", () => {
it("Login", async () => {
await TestBuilder.create()
.prepare()
.addStep(login)
.wait(3000)
.isNotVisibleByText("Notesnook Plans")
.run();
await TestBuilder.create().prepare().addStep(login).run();
});
});

View File

@@ -315,6 +315,12 @@ class TestBuilder {
});
}
replaceTextById(id: string, text: string) {
return this.addStep(async () => {
await Element.fromId(id).element.replaceText(text);
});
}
clearTextById(id: string) {
return this.addStep(async () => {
await Element.fromId(id).element.clearText();

View File

@@ -9,6 +9,9 @@ import "./app/common/logger/index";
import { setI18nGlobal } from "@notesnook/intl";
import { i18n } from "@lingui/core";
import Config from "react-native-config";
import OpenPGP from "react-native-fast-openpgp";
OpenPGP.useJSI = false;
let domParser;
Object.defineProperty(global, "DOMParser", {

View File

@@ -52,6 +52,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia</string>
<string>nn</string>
</array>
</dict>
<dict>

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
"version": "3.3.17",
"version": "3.3.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
"version": "3.3.17",
"version": "3.3.18",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -84,6 +84,7 @@
"react-native-device-info": "^14.1.1",
"react-native-drax": "^0.10.2",
"react-native-exit-app": "github:ammarahm-ed/react-native-exit-app",
"react-native-fast-openpgp": "^2.9.3",
"react-native-file-viewer": "^2.1.1",
"react-native-format-currency": "0.0.5",
"react-native-gesture-handler": "2.28.0",
@@ -10882,6 +10883,12 @@
"node": ">=16"
}
},
"node_modules/flatbuffers": {
"version": "24.3.25",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.3.25.tgz",
"integrity": "sha512-3HDgPbgiwWMI9zVB7VYBHaMrbOO7Gm0v+yD2FV/sCKj+9NDeVL7BOBYUuhWAQGKWOzBo8S9WdMvV0eixO233XQ==",
"license": "Apache-2.0"
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
@@ -17531,6 +17538,26 @@
"resolved": "git+ssh://git@github.com/ammarahm-ed/react-native-exit-app.git#3087d4bce1320227384d24b34b354600457a817d",
"license": "MIT"
},
"node_modules/react-native-fast-openpgp": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/react-native-fast-openpgp/-/react-native-fast-openpgp-2.9.3.tgz",
"integrity": "sha512-JF+h0e45mi1kHSYfpXIDWnH5x3uFHU8DqGsuqZo7JWKfmE6MF7ppwvI4dyc5rUdGoZI752IdxI33F86UcR/fnQ==",
"license": "MIT",
"workspaces": [
"example"
],
"dependencies": {
"big-integer": "^1.6.51",
"flatbuffers": "24.3.25"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-file-viewer": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/react-native-file-viewer/-/react-native-file-viewer-2.1.5.tgz",

View File

@@ -101,6 +101,7 @@
"react-native-device-info": "^14.1.1",
"react-native-drax": "^0.10.2",
"react-native-exit-app": "github:ammarahm-ed/react-native-exit-app",
"react-native-fast-openpgp": "^2.9.3",
"react-native-file-viewer": "^2.1.1",
"react-native-format-currency": "0.0.5",
"react-native-gesture-handler": "2.28.0",

View File

@@ -61,6 +61,7 @@ const EXTRA_ICON_NAMES = [
"checkbox-marked",
"checkbox-blank-outline",
"unfold-less-horizontal",
"close-box-multiple-outline",
"minus-circle",
"vibrate",
"volume-high",
@@ -121,7 +122,10 @@ const EXTRA_ICON_NAMES = [
"calendar-today",
"bomb",
"bomb-off",
"cancel"
"cancel",
"inbox",
"identifier",
"image-area"
];
const __filename = fileURLToPath(import.meta.url);

View File

@@ -18,6 +18,9 @@
],
"@notesnook/editor/dist/cjs/toolbar/icons": [
"../../packages/editor/dist/cjs/toolbar/icons.js"
],
"@notesnook/editor/dist/cjs/utils/font": [
"../../packages/editor/dist/cjs/utils/font.js"
]
}
},

View File

@@ -0,0 +1,67 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Flex, Text } from "@theme-ui/components";
import { MonographPage } from "./monographpost";
import { Header } from "./header";
import { Footer } from "./footer";
import { Monograph } from "./monographpost/types";
type MonographViewProps = {
monograph: Monograph | null;
pixel: string | null;
encodedKey: string | undefined;
};
export function MonographView({
monograph,
pixel,
encodedKey
}: MonographViewProps) {
return (
<>
{monograph ? (
<MonographPage
monograph={monograph}
encodedKey={encodedKey}
pixel={pixel ?? undefined}
/>
) : (
<>
<Header />
<Flex
sx={{
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
flex: 1,
height: "100vh",
bg: "background"
}}
>
<Text variant="heading" sx={{ fontSize: 42, mt: 20 }}>
404
</Text>
<Text variant="body">This monograph does not exist.</Text>
</Flex>
<Footer />
</>
)}
</>
);
}

View File

@@ -108,11 +108,11 @@ function generateTableOfContents() {
export const MonographPage = ({
monograph,
encodedKey,
apiHost
pixel
}: {
monograph: Monograph;
encodedKey?: string;
apiHost: string;
pixel?: string;
}) => {
const [reportDialogVisible, setReportDialogVisible] = useState(false);
const [tableOfContents, setTableOfContents] = useState<TableOfContent[]>([]);
@@ -227,10 +227,7 @@ export const MonographPage = ({
</Text>
</Button>
)}
<Image
sx={{ display: "none" }}
src={`${apiHost}/monographs/${monograph.id}/view`}
/>
{pixel ? <Image sx={{ display: "none" }} src={pixel} /> : null}
</Flex>
</Box>
<Flex

View File

@@ -28,4 +28,5 @@ export type Monograph = {
encryptedContent?: Cipher<"base64">;
datePublished: string;
id: string;
slug?: string;
};

View File

@@ -17,55 +17,25 @@ 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 type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node";
import { Cipher } from "@notesnook/crypto";
import { Flex, Text } from "@theme-ui/components";
import { useLoaderData } from "@remix-run/react";
import { MonographPage } from "../components/monographpost";
import { useHashLocation } from "../utils/use-hash-location";
import { isSpam, isSpamCached } from "../utils/spam-filter.server";
import { Header } from "../components/header";
import { Footer } from "../components/footer";
import { API_HOST, PUBLIC_URL } from "../utils/env";
import { generateMetaDescriptors } from "../utils/meta";
import { format } from "date-fns/format";
type Monograph = {
title: string;
userId: string;
content?: {
type: string;
data: string;
};
selfDestruct: boolean;
encryptedContent?: Cipher<"base64">;
datePublished: string;
id: string;
};
import {
buildMonographMeta,
getMonographMetadata,
NOT_FOUND_LOADER_DATA
} from "../utils/meta";
import { MonographView } from "../components/monograph-view";
import { useHashLocation } from "../utils/use-hash-location";
import { Monograph } from "../components/monographpost/types";
type MonographResponse = Omit<Monograph, "content"> & { content: string };
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data || !data.metadata || !data.monograph) return [];
const imageUrl = `${PUBLIC_URL}/api/og.jpg?${new URLSearchParams({
title: data?.metadata?.title || "",
description: data?.metadata?.fullDescription
? Buffer.from(data.metadata.fullDescription, "utf-8").toString("base64")
: "",
date: data?.metadata?.datePublished || ""
}).toString()}`;
return generateMetaDescriptors({
titleFull: data?.metadata.title + " - Monograph",
titleShort: data?.metadata.title,
description: data?.metadata.shortDescription,
imageAlt: data?.metadata.fullDescription,
imageUrl: imageUrl,
url: data?.monograph ? `${PUBLIC_URL}/${data?.monograph.id}` : undefined,
publishedAt: data?.metadata.datePublished,
type: "article"
});
};
export const meta: MetaFunction<typeof loader> = ({ data }) =>
buildMonographMeta(
data,
data?.monograph ? `${PUBLIC_URL}/${data.monograph.id}` : undefined
);
export async function loader({ params }: LoaderFunctionArgs) {
try {
@@ -86,122 +56,22 @@ export async function loader({ params }: LoaderFunctionArgs) {
return {
monograph,
metadata,
apiHost: API_HOST
pixel: `${API_HOST}/monographs/${monographId}/view`
};
} catch (e) {
// console.error(e);
return {
monograph: null,
metadata: {
title: "Not found",
fullDescription: "This monograph does not exist.",
shortDescription: "This monograph does not exist.",
datePublished: ""
},
apiHost: API_HOST
};
return NOT_FOUND_LOADER_DATA;
}
}
export default function MonographPost() {
const { monograph, apiHost } = useLoaderData<typeof loader>();
const { monograph, pixel } = useLoaderData<typeof loader>();
const [_, hashParams] = useHashLocation();
return (
<>
{monograph ? (
<MonographPage
monograph={monograph}
encodedKey={hashParams.key}
apiHost={apiHost}
/>
) : (
<>
<Header />
<Flex
sx={{
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
flex: 1,
height: "100vh",
bg: "background"
}}
>
<Text variant="heading" sx={{ fontSize: 42, mt: 20 }}>
404
</Text>
<Text variant="body">This monograph does not exist.</Text>
</Flex>
<Footer />
</>
)}
</>
<MonographView
monograph={monograph}
pixel={pixel}
encodedKey={hashParams.key}
/>
);
}
type Metadata = {
title: string;
fullDescription: string;
shortDescription: string;
datePublished: string;
};
function extractFirstWords(html: string, numWords = 30): string {
// Strip HTML tags and normalize whitespace
const plainText = html
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
// Split into words and take first N
const words = plainText.split(" ").slice(0, numWords);
// Add ellipsis if text was truncated
const excerpt = words.join(" ");
return words.length < plainText.split(" ").length ? excerpt + "..." : excerpt;
}
function trimDescription(
str: string,
length: number,
collapse = false
): string {
if (collapse) str = str.replace(/\n/gm, " ").replace(/\s+/gm, " ");
const index = str.indexOf(".", length) - 1;
return addPeriod(
str.substring(
0,
index < 0 ? Math.min(str.length, length) : Math.min(index, length)
)
);
}
const NOT_ALPHA_REGEX = /[^\w\s']|_/g;
function addPeriod(str: string) {
str = str.trim();
const lastChar = str[str.length - 1];
if (lastChar === ".") return str;
if (NOT_ALPHA_REGEX.test(lastChar)) str = str.slice(0, str.length - 1);
return str + "...";
}
function getMonographMetadata(monograph: Monograph): Metadata {
const title = monograph?.title || "Not found";
const text = monograph?.encryptedContent
? "This monograph is encrypted. Enter password to view contents."
: monograph?.content
? extractFirstWords(monograph?.content.data, 100)
: "";
const shortDescription = trimDescription(text, 150, true);
const fullDescription = trimDescription(text, 300, true);
const datePublished = monograph
? format(monograph.datePublished, "yyyy-MM-dd HH:mm")
: "";
return {
title,
fullDescription,
shortDescription,
datePublished
};
}

View File

@@ -0,0 +1,77 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { isSpam, isSpamCached } from "../utils/spam-filter.server";
import { API_HOST, PUBLIC_URL } from "../utils/env";
import {
buildMonographMeta,
getMonographMetadata,
NOT_FOUND_LOADER_DATA
} from "../utils/meta";
import { MonographView } from "../components/monograph-view";
import { useHashLocation } from "../utils/use-hash-location";
import { Monograph } from "../components/monographpost/types";
type MonographResponse = Omit<Monograph, "content"> & { content: string };
export const meta: MetaFunction<typeof loader> = ({ data }) =>
buildMonographMeta(
data,
data?.monograph ? `${PUBLIC_URL}/s/${data.monograph.id}` : undefined
);
export default function MonographPost() {
const { monograph, pixel } = useLoaderData<typeof loader>();
const [_, hashParams] = useHashLocation();
return (
<MonographView
monograph={monograph}
pixel={pixel}
encodedKey={hashParams.key}
/>
);
}
export async function loader({ params }: LoaderFunctionArgs) {
try {
const slug = params["slug"];
if (slug && (await isSpamCached(slug))) throw new Error();
const monograph = await fetch(`${API_HOST}/monographs/v2/${slug}`)
.then((r) => r.json() as Promise<MonographResponse>)
.then(
(data) => ({ ...data, content: JSON.parse(data.content) } as Monograph)
);
if (!monograph.encryptedContent && (await isSpam(monograph)))
throw new Error();
const metadata = getMonographMetadata(monograph);
return {
monograph,
metadata,
pixel: `${API_HOST}/monographs/v2/${slug}/view`
};
} catch (e) {
// console.error(e);
return NOT_FOUND_LOADER_DATA;
}
}

View File

@@ -17,6 +17,9 @@ 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 { ServerRuntimeMetaDescriptor } from "@remix-run/server-runtime";
import { Monograph } from "../components/monographpost/types";
import { format } from "date-fns/format";
import { PUBLIC_URL } from "./env";
type MetaProps = {
titleFull: string;
@@ -70,3 +73,112 @@ export function generateMetaDescriptors(
return descriptors;
}
type Metadata = {
title: string;
fullDescription: string;
shortDescription: string;
datePublished: string;
};
function extractFirstWords(html: string, numWords = 30): string {
// Strip HTML tags and normalize whitespace
const plainText = html
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
// Split into words and take first N
const words = plainText.split(" ").slice(0, numWords);
// Add ellipsis if text was truncated
const excerpt = words.join(" ");
return words.length < plainText.split(" ").length ? excerpt + "..." : excerpt;
}
function trimDescription(
str: string,
length: number,
collapse = false
): string {
if (collapse) str = str.replace(/\n/gm, " ").replace(/\s+/gm, " ");
const index = str.indexOf(".", length) - 1;
return addPeriod(
str.substring(
0,
index < 0 ? Math.min(str.length, length) : Math.min(index, length)
)
);
}
const NOT_ALPHA_REGEX = /[^\w\s']|_/g;
function addPeriod(str: string) {
str = str.trim();
const lastChar = str[str.length - 1];
if (lastChar === ".") return str;
if (NOT_ALPHA_REGEX.test(lastChar)) str = str.slice(0, str.length - 1);
return str + "...";
}
export function getMonographMetadata(monograph: Monograph): Metadata {
const title = monograph?.title || "Not found";
const text = monograph?.encryptedContent
? "This monograph is encrypted. Enter password to view contents."
: monograph?.content
? extractFirstWords(monograph?.content.data, 100)
: "";
const shortDescription = trimDescription(text, 150, true);
const fullDescription = trimDescription(text, 300, true);
const datePublished = monograph
? format(monograph.datePublished, "yyyy-MM-dd HH:mm")
: "";
return {
title,
fullDescription,
shortDescription,
datePublished
};
}
export type MonographLoaderData = {
monograph: Monograph | null;
metadata: Metadata;
pixel: string | null;
};
export const NOT_FOUND_LOADER_DATA: MonographLoaderData = {
monograph: null,
metadata: {
title: "Not found",
fullDescription: "This monograph does not exist.",
shortDescription: "This monograph does not exist.",
datePublished: ""
},
pixel: null
};
export function buildMonographMeta(
data: MonographLoaderData | undefined,
url: string | undefined
): ServerRuntimeMetaDescriptor[] {
if (!data || !data.metadata || !data.monograph) return [];
const imageUrl = `${PUBLIC_URL}/api/og.jpg?${new URLSearchParams({
title: data.metadata.title || "",
description: data.metadata.fullDescription
? Buffer.from(data.metadata.fullDescription, "utf-8").toString("base64")
: "",
date: data.metadata.datePublished || ""
}).toString()}`;
return generateMetaDescriptors({
titleFull: data.metadata.title + " - Monograph",
titleShort: data.metadata.title,
description: data.metadata.shortDescription,
imageAlt: data.metadata.fullDescription,
imageUrl,
url,
publishedAt: data.metadata.datePublished,
type: "article"
});
}

View File

@@ -148,7 +148,7 @@
"@notesnook/intl": "file:../intl",
"@notesnook/theme": "file:../theme",
"@notesnook/ui": "file:../ui",
"@social-embed/lib": "^0.1.0-next.7",
"@social-embed/lib": "^0.1.0-next.11",
"@tiptap/core": "2.6.6",
"@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6",
@@ -2950,6 +2950,22 @@
"@styled-system/css": "^5.1.5"
}
},
"node_modules/@theme-ui/color-modes": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@theme-ui/color-modes/-/color-modes-0.16.2.tgz",
"integrity": "sha512-jWEWx53lxNgWCT38i/kwLV2rsvJz8lVZgi5oImnVwYba9VejXD23q1ckbNFJHosQ8KKXY87ht0KPC6BQFIiHtQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@theme-ui/core": "^0.16.2",
"@theme-ui/css": "^0.16.2",
"deepmerge": "^4.2.2"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@theme-ui/components": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@theme-ui/components/-/components-0.16.2.tgz",
@@ -2995,6 +3011,22 @@
"@emotion/react": "^11.11.1"
}
},
"node_modules/@theme-ui/theme-provider": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@theme-ui/theme-provider/-/theme-provider-0.16.2.tgz",
"integrity": "sha512-LRnVevODcGqO0JyLJ3wht+PV3ZoZcJ7XXLJAJWDoGeII4vZcPQKwVy4Lpz/juHsZppQxKcB3U+sQDGBnP25irQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@theme-ui/color-modes": "^0.16.2",
"@theme-ui/core": "^0.16.2",
"@theme-ui/css": "^0.16.2"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@types/acorn": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz",
@@ -3124,14 +3156,14 @@
"version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz",
"integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -12239,7 +12271,7 @@
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

File diff suppressed because it is too large Load Diff

View File

@@ -114,6 +114,7 @@
"happy-dom": "16.0.1",
"ip": "^2.0.1",
"lorem-ipsum": "^2.0.4",
"openpgp": "^6.2.2",
"otplib": "^13.3.0",
"rollup-plugin-visualizer": "^5.13.1",
"vite": "5.4.11",

View File

@@ -25,16 +25,17 @@ import { useStore as useAnnouncementStore } from "./stores/announcement-store";
import { useStore as useSettingStore } from "./stores/setting-store";
import { scheduleBackups, scheduleFullBackups } from "./common/notices";
import {
handleInternalLink,
introduceFeatures,
resetFeatures,
scheduleExpiredNotesDeletion
} from "./common";
import { AppEventManager, AppEvents } from "./common/app-events";
import { db } from "./common/db";
import { EVENTS } from "@notesnook/core";
import { EVENTS, parseInternalLink } from "@notesnook/core";
import { registerKeyMap } from "./common/key-map";
import { updateStatus, removeStatus, getStatus } from "./hooks/use-status";
import { hashNavigate } from "./navigation";
import { hashNavigate, navigate } from "./navigation";
import { desktop } from "./common/desktop-bridge";
import { FeatureDialog } from "./dialogs/feature-dialog";
import { AnnouncementDialog } from "./dialogs/announcement-dialog";
@@ -56,9 +57,10 @@ export default function AppEffects() {
useEffect(
function initializeApp() {
initStore();
initEditorStore();
(async function () {
await initEditorStore();
await attachDesktopListeners();
await resetFeatures();
await refreshNavItems();
await updateLastSynced();
@@ -201,29 +203,6 @@ export default function AppEffects() {
})();
}, [dialogAnnouncements]);
useEffect(() => {
const { unsubscribe } =
desktop?.bridge.onCreateItem.subscribe(undefined, {
onData(itemType) {
switch (itemType) {
case "note":
useEditorStore.getState().newSession();
break;
case "notebook":
hashNavigate("/notebooks/create", { replace: true });
break;
case "reminder":
hashNavigate("/reminders/create", { replace: true });
break;
}
}
}) || {};
return () => {
unsubscribe?.();
};
}, []);
return <React.Fragment />;
}
@@ -240,3 +219,27 @@ function getProcessingStatusFromType(type: ProcessingType) {
return [];
}
}
async function attachDesktopListeners() {
const handlers = [
AppEventManager.subscribe(AppEvents.onCreateItem, onCreateItem),
AppEventManager.subscribe(AppEvents.onOpenLink, handleInternalLink)
];
await desktop?.bridge.ready.query();
return handlers;
}
async function onCreateItem(itemType: string) {
switch (itemType) {
case "note":
useEditorStore.getState().newSession();
break;
case "notebook":
hashNavigate("/notebooks/create", { replace: true });
break;
case "reminder":
hashNavigate("/reminders/create", { replace: true });
break;
}
}

View File

@@ -41,5 +41,8 @@ export const AppEvents = {
revealItemInList: "list:revealItem",
toggleSideMenu: "app:openSideMenu",
toggleEditor: "app:toggleEditor"
toggleEditor: "app:toggleEditor",
onOpenLink: "onOpenLink",
onCreateItem: "onCreateItem"
};

View File

@@ -24,8 +24,6 @@ import { AppEventManager, AppEvents } from "../app-events";
import { TaskScheduler } from "../../utils/task-scheduler";
import { checkForUpdate } from "../../utils/updater";
import { showToast } from "../../utils/toast";
import { db } from "../db";
import { logManager } from "@notesnook/core";
import { store as settingStore } from "../../stores/setting-store";
export const desktop: ReturnType<typeof createTRPCProxyClient<AppRouter>> =
@@ -67,6 +65,16 @@ function attachListeners() {
attachListener(AppEvents.updateError)
);
desktop.bridge.onOpenLink.subscribe(
undefined,
attachListener(AppEvents.onOpenLink)
);
desktop.bridge.onCreateItem.subscribe(
undefined,
attachListener(AppEvents.onCreateItem)
);
// desktop.window.onClose.subscribe(undefined, {
// async onData() {
// try {

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Config from "../utils/config";
import { hashNavigate, getCurrentHash } from "../navigation";
import { hashNavigate, getCurrentHash, navigate } from "../navigation";
import { db } from "./db";
import {
areFeaturesAvailable,
@@ -39,7 +39,7 @@ import { readFile, showFilePicker } from "../utils/file-picker";
import { logger } from "../utils/logger";
import { PATHS } from "@notesnook/desktop";
import { TaskManager } from "./task-manager";
import { EVENTS } from "@notesnook/core";
import { EVENTS, parseInternalLink } from "@notesnook/core";
import { createWritableStream } from "./desktop-bridge";
import { FeatureDialog, FeatureKeys } from "../dialogs/feature-dialog";
import { User } from "@notesnook/core";
@@ -569,3 +569,20 @@ export async function scheduleExpiredNotesDeletion() {
await db.notes.deleteExpiredNotes();
});
}
export async function handleInternalLink(url: string, openInNewTab?: boolean) {
const link = parseInternalLink(url);
if (!link) return;
if (link.type === "note") {
await useEditorStore.getState().openSession(link.id, {
activeBlockId: link.params?.blockId || undefined,
openInNewTab
});
} else if (link.type === "notebook") {
navigate(`/notebooks/${link.id}`);
} else if (link.type === "tag") {
navigate(`/tags/${link.id}`);
} else if (link.type === "color") {
navigate(`/colors/${link.id}`);
}
}

View File

@@ -68,6 +68,8 @@ import { showFeatureNotAllowedToast } from "../../common/toasts";
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
import { ConfirmDialog } from "../../dialogs/confirm";
import { strings } from "@notesnook/intl";
import { handleInternalLink } from "../../common";
import { db } from "../../common/db";
export type OnChangeHandler = (
content: () => string,
@@ -423,12 +425,50 @@ function TipTap(props: TipTapProps) {
getAttachmentData: onGetAttachmentData,
openLink: (url, openInNewTab) => {
const link = parseInternalLink(url);
if (link && link.type === "note") {
useEditorStore.getState().openSession(link.id, {
activeBlockId: link.params?.blockId || undefined,
openInNewTab: openInNewTab
});
} else window.open(url, "_blank");
if (link) handleInternalLink(url, openInNewTab);
else window.open(url, "_blank");
},
getLinkData: async (url) => {
const link = parseInternalLink(url);
if (!link) return;
switch (link.type) {
case "note":
case "notebook":
case "tag": {
const table =
link.type === "note"
? "notes"
: link.type === "notebook"
? "notebooks"
: "tags";
const item = await db
.sql()
.selectFrom(table)
.where("id", "=", link.id)
.select("title")
.executeTakeFirst();
return {
type: link.type,
title: item?.title || ""
};
}
case "color": {
const color = await db
.sql()
.selectFrom("colors")
.where("id", "=", link.id)
.select(["title", "colorCode"])
.executeTakeFirst();
return {
type: "color",
title: color?.title || "",
metadata: {
colorCode: color?.colorCode || ""
}
};
}
}
}
};
}, [

View File

@@ -47,7 +47,8 @@ import {
Notebook as NotebookIcon,
Plus,
SortBy,
Tag as TagIcon
Tag as TagIcon,
InternalLink
} from "../icons";
import { SortableNavigationItem } from "./navigation-item";
import {
@@ -89,7 +90,7 @@ import {
} from "@dnd-kit/sortable";
import { usePersistentState } from "../../hooks/use-persistent-state";
import { MenuItem } from "@notesnook/ui";
import { Color, Notebook, Tag } from "@notesnook/core";
import { Color, createInternalLink, Notebook, Tag } from "@notesnook/core";
import { handleDrop } from "../../common/drop-handler";
import { Menu, useMenuStore, useMenuTrigger } from "../../hooks/use-menu";
import { RenameColorDialog } from "../../dialogs/item-dialog";
@@ -117,6 +118,7 @@ import {
} from "@notesnook/common";
import { isUserSubscribed } from "../../hooks/use-is-user-premium";
import { shouldShowWrapped } from "../../utils/should-show-wrapped";
import { writeToClipboard } from "../../utils/clipboard";
type Route = {
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
@@ -641,9 +643,23 @@ function ColorItem({
},
icon: Trash.path
},
{
type: "button",
key: "copy-link",
title: strings.copyLink(),
icon: InternalLink.path,
onClick: () => {
const link = createInternalLink("color", color.id);
writeToClipboard({
"text/plain": link,
"text/html": `<a href="${link}">${color.title}</a>`,
"text/markdown": `[${color.title}](${link})`
});
}
},
{
type: "lazy-loader",
key: "sidebar-items-loader",
key: "sidebar-items-loader2",
items: async () => [
createSetDefaultHomepageMenuItem(
color.id,

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import ListItem from "../list-item";
import { Button, Flex, Text } from "@theme-ui/components";
import { useStore as useNotesStore } from "../../stores/note-store";
import { Notebook as NotebookType } from "@notesnook/core";
import { createInternalLink, Notebook as NotebookType } from "@notesnook/core";
import {
ChevronDown,
ChevronRight,
@@ -31,7 +31,9 @@ import {
Trash,
Notebook as NotebookIcon,
ArrowUp,
Move
Move,
Copy,
InternalLink
} from "../icons";
import { MenuItem } from "@notesnook/ui";
import { hashNavigate, navigate } from "../../navigation";
@@ -51,6 +53,7 @@ import {
import { useStore as useNotebookStore } from "../../stores/notebook-store";
import { MoveNotebookDialog } from "../../dialogs/move-notebook-dialog";
import { areFeaturesAvailable } from "@notesnook/common";
import { writeToClipboard } from "../../utils/clipboard";
type NotebookProps = {
item: NotebookType;
@@ -254,6 +257,20 @@ export const notebookMenuItems: (
MoveNotebookDialog.show({ notebook: notebook });
}
},
{
type: "button",
key: "copy-link",
title: strings.copyLink(),
icon: InternalLink.path,
onClick: () => {
const link = createInternalLink("notebook", notebook.id);
writeToClipboard({
"text/plain": link,
"text/html": `<a href="${link}">${notebook.title}</a>`,
"text/markdown": `[${notebook.title}](${link})`
});
}
},
{
type: "button",
key: "move-to-top",

View File

@@ -419,7 +419,7 @@ function LinkedNote({
const linkedBlocks = usePromise(
async () =>
(await db.notes.internalLinks(noteId)).filter(
(l) => l.id === item.id && !!l.params?.blockId
(l) => l.id === item.id && l.type === "note" && !!l.params?.blockId
),
[item.id]
);
@@ -453,7 +453,9 @@ function LinkedNote({
if (isExpanded) return toggleExpand();
setBlocks(
(await db.notes.contentBlocks(item.id)).filter((a) =>
linkedBlocks.value.some((l) => l.params?.blockId === a.id)
linkedBlocks.value.some(
(l) => l.type === "note" && l.params?.blockId === a.id
)
)
);
toggleExpand();

View File

@@ -23,7 +23,7 @@ import { Loading, Refresh } from "../icons";
import { db } from "../../common/db";
import { writeText } from "clipboard-polyfill";
import { showToast } from "../../utils/toast";
import { EVENTS, hosts } from "@notesnook/core";
import { EVENTS } from "@notesnook/core";
import { useStore } from "../../stores/monograph-store";
import { Note } from "@notesnook/core";
import { strings } from "@notesnook/intl";
@@ -61,10 +61,10 @@ function PublishView(props: PublishViewProps) {
const unpublishNote = useStore((store) => store.unpublish);
const [monograph, setMonograph] = useState(props.monograph);
const monographAnalytics = useIsFeatureAvailable("monographAnalytics");
const analytics = usePromise(async () => {
if (!monographAnalytics?.isAllowed || !monograph) return { totalViews: 0 };
return await db.monographs.analytics(monograph?.id);
}, [monograph?.id, monographAnalytics]);
const metadata = usePromise(async () => {
if (!monograph) return { publishUrl: "", analytics: { totalViews: 0 } };
return await db.monographs.metadata(monograph.id);
}, [monograph?.id]);
useEffect(() => {
const fileDownloadedEvent = db.eventManager.subscribe(
@@ -96,23 +96,31 @@ function PublishView(props: PublishViewProps) {
variant="text.body"
as="a"
target="_blank"
href={`${hosts.MONOGRAPH_HOST}/${monograph?.id}`}
href={
metadata.status === "fulfilled" ? metadata.value.publishUrl : "#"
}
sx={{
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
overflow: "hidden",
px: 1
px: 1,
opacity: metadata.status === "fulfilled" ? 1 : 0.8
}}
>
{`${hosts.MONOGRAPH_HOST}/${monograph?.id}`}
{metadata.status === "fulfilled"
? metadata.value.publishUrl
: monograph?.publishUrl}
</Link>
<Button
variant="secondary"
className="copyPublishLink"
sx={{ flexShrink: 0, m: 0 }}
disabled={metadata.status !== "fulfilled"}
onClick={() => {
writeText(`${hosts.MONOGRAPH_HOST}/${monograph?.id}`);
if (metadata.status !== "fulfilled") return;
writeText(metadata.value.publishUrl);
}}
>
{strings.copy()}
@@ -172,7 +180,7 @@ function PublishView(props: PublishViewProps) {
>
<Text variant="body">{strings.views()}</Text>
{monographAnalytics?.isAllowed ? (
analytics.status === "fulfilled" ? (
metadata.status === "fulfilled" ? (
<Flex sx={{ alignItems: "center", gap: 1 }}>
<Text
variant="body"
@@ -180,14 +188,14 @@ function PublishView(props: PublishViewProps) {
color: "paragraph-secondary"
}}
>
{analytics.value.totalViews}
{metadata.value.analytics.totalViews}
</Text>
<Button
variant="tertiary"
onClick={async () => {
try {
setStatus({ action: "analytics" });
analytics.refresh();
metadata.refresh();
} finally {
setStatus(undefined);
}
@@ -453,6 +461,7 @@ type ResolvedMonograph = {
publishedAt?: number;
password?: string;
title: string;
publishUrl?: string;
};
async function resolveMonograph(
@@ -463,6 +472,7 @@ async function resolveMonograph(
return {
id: monographId,
selfDestruct: !!monograph.selfDestruct,
publishUrl: monograph.publishUrl,
publishedAt: monograph.datePublished,
title: monograph.title,
password: monograph.password

View File

@@ -22,9 +22,15 @@ import { navigate } from "../../navigation";
import { Flex, Text } from "@theme-ui/components";
import { store as appStore } from "../../stores/app-store";
import { db } from "../../common/db";
import { Edit, Shortcut, DeleteForver, Tag as TagIcon } from "../icons";
import {
Edit,
Shortcut,
DeleteForver,
Tag as TagIcon,
InternalLink
} from "../icons";
import { MenuItem } from "@notesnook/ui";
import { Tag as TagType } from "@notesnook/core";
import { createInternalLink, Tag as TagType } from "@notesnook/core";
import { handleDrop } from "../../common/drop-handler";
import { EditTagDialog } from "../../dialogs/item-dialog";
import { useStore as useSelectionStore } from "../../stores/selection-store";
@@ -36,6 +42,7 @@ import {
withFeatureCheck
} from "../../common";
import { areFeaturesAvailable } from "@notesnook/common";
import { writeToClipboard } from "../../utils/clipboard";
type TagProps = { item: TagType; totalNotes: number };
function Tag(props: TagProps) {
@@ -149,6 +156,20 @@ export const tagMenuItems: (
appStore.addToShortcuts(tag)
)
},
{
type: "button",
key: "copy-link",
title: strings.copyLink(),
icon: InternalLink.path,
onClick: () => {
const link = createInternalLink("tag", tag.id);
writeToClipboard({
"text/plain": link,
"text/html": `<a href="${link}">${tag.title}</a>`,
"text/markdown": `[${tag.title}](${link})`
});
}
},
{ key: "sep", type: "separator" },
{
type: "button",

View File

@@ -0,0 +1,210 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useState } from "react";
import { Button, Flex, Text } from "@theme-ui/components";
import Dialog from "../components/dialog";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { db } from "../common/db";
import Field from "../components/field";
import { showToast } from "../utils/toast";
import { SerializedKeyPair } from "@notesnook/crypto";
import { ConfirmDialog } from "./confirm";
type InboxPGPKeysDialogProps = BaseDialogProps<boolean> & {
keys?: SerializedKeyPair | null;
};
export const InboxPGPKeysDialog = DialogManager.register(
function InboxPGPKeysDialog(props: InboxPGPKeysDialogProps) {
const { keys: initialKeys, onClose } = props;
const [mode, setMode] = useState<"choose" | "edit">(
initialKeys ? "edit" : "choose"
);
const [publicKey, setPublicKey] = useState(initialKeys?.publicKey || "");
const [privateKey, setPrivateKey] = useState(initialKeys?.privateKey || "");
const [isLoading, setIsLoading] = useState(false);
const hasChanges =
publicKey !== (initialKeys?.publicKey || "") ||
privateKey !== (initialKeys?.privateKey || "");
async function handleAutoGenerate() {
try {
setIsLoading(true);
await db.user.getInboxKeys();
showToast("success", "Inbox keys generated");
onClose(true);
} catch (error) {
showToast("error", "Failed to generate inbox keys");
console.error(error);
} finally {
setIsLoading(false);
}
}
async function handleSave() {
const trimmedPublicKey = publicKey.trim();
const trimmedPrivateKey = privateKey.trim();
if (!trimmedPublicKey || !trimmedPrivateKey) {
showToast("error", "Both public and private keys are required");
return;
}
try {
setIsLoading(true);
const isValid = await db.storage().validatePGPKeyPair({
publicKey: trimmedPublicKey,
privateKey: trimmedPrivateKey
});
if (!isValid) {
showToast(
"error",
"Invalid PGP key pair. Please check your keys and try again."
);
return;
}
if (initialKeys) {
const ok = await ConfirmDialog.show({
title: "Change Inbox PGP Keys",
message:
"Changing Inbox PGP keys will delete all your unsynced inbox items. Are you sure?",
positiveButtonText: "Yes",
negativeButtonText: "No"
});
if (!ok) return;
}
await db.user.saveInboxKeys({
publicKey: trimmedPublicKey,
privateKey: trimmedPrivateKey
});
showToast("success", "Inbox keys saved");
onClose(true);
} catch (error) {
showToast("error", "Failed to save inbox keys");
console.error(error);
} finally {
setIsLoading(false);
}
}
if (mode === "choose") {
return (
<Dialog
isOpen={true}
title="Setup Inbox PGP Keys"
width={500}
negativeButton={{
text: "Cancel",
onClick: () => onClose(false)
}}
>
<Flex sx={{ flexDirection: "column", gap: 3 }}>
<Text sx={{ fontSize: "body", color: "paragraph" }}>
Choose how you want to set up your Inbox PGP keys:
</Text>
<Flex sx={{ flexDirection: "column", gap: 2 }}>
<Button
variant="secondary"
onClick={handleAutoGenerate}
disabled={isLoading}
sx={{ width: "100%" }}
>
{isLoading ? "Generating..." : "Auto-generate keys"}
</Button>
<Text
sx={{
fontSize: "body",
color: "paragraph",
textAlign: "center"
}}
>
Or
</Text>
<Button
variant="secondary"
onClick={() => setMode("edit")}
disabled={isLoading}
sx={{ width: "100%" }}
>
Provide your own keys
</Button>
</Flex>
</Flex>
</Dialog>
);
}
return (
<Dialog
isOpen={true}
title="Inbox PGP Keys"
width={600}
positiveButton={{
text: isLoading ? "Saving..." : "Save",
onClick: handleSave,
disabled: isLoading || !hasChanges
}}
negativeButton={{
text: "Cancel",
onClick: () => onClose(false)
}}
>
<Flex sx={{ flexDirection: "column", gap: 3 }}>
<Field
label="Public Key"
id="publicKey"
name="publicKey"
as="textarea"
required
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
sx={{
fontFamily: "monospace",
fontSize: "body",
minHeight: 150,
resize: "vertical"
}}
placeholder="Enter your PGP public key..."
disabled={isLoading}
/>
<Field
label="Private Key"
id="privateKey"
name="privateKey"
as="textarea"
required
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
sx={{
fontFamily: "monospace",
fontSize: "body",
minHeight: 150,
resize: "vertical"
}}
placeholder="Enter your PGP private key..."
disabled={isLoading}
/>
</Flex>
</Dialog>
);
}
);

View File

@@ -20,6 +20,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { SettingsGroup } from "./types";
import { useStore as useSettingStore } from "../../stores/setting-store";
import { InboxApiKeys } from "./components/inbox-api-keys";
import { InboxPGPKeysDialog } from "../inbox-pgp-keys-dialog";
import { db } from "../../common/db";
import { showPasswordDialog } from "../password-dialog";
import { strings } from "@notesnook/intl";
export const InboxSettings: SettingsGroup[] = [
{
@@ -42,6 +46,41 @@ export const InboxSettings: SettingsGroup[] = [
}
]
},
{
key: "show-inbox-pgp-keys",
title: "Inbox PGP Keys",
description: "View/edit your Inbox PGP keys",
keywords: ["inbox", "pgp", "keys"],
onStateChange: (listener) =>
useSettingStore.subscribe((s) => s.isInboxEnabled, listener),
isHidden: () => !useSettingStore.getState().isInboxEnabled,
components: [
{
type: "button",
title: "Show",
variant: "secondary",
action: async () => {
const ok = await showPasswordDialog({
title: "Authenticate to view/edit Inbox PGP keys",
inputs: {
password: {
label: strings.accountPassword(),
autoComplete: "current-password"
}
},
validate: ({ password }) => {
return db.user.verifyPassword(password);
}
});
if (!ok) return;
InboxPGPKeysDialog.show({
keys: await db.user.getInboxKeys()
});
}
}
]
},
{
key: "inbox-api-keys",
title: "",

View File

@@ -26,7 +26,6 @@ import {
} from "./key-value";
import { NNCrypto } from "./nncrypto";
import type {
AsymmetricCipher,
Cipher,
SerializedKey,
SerializedKeyPair
@@ -34,6 +33,7 @@ import type {
import { isFeatureSupported } from "../utils/feature-check";
import { IKeyStore } from "./key-store";
import { User } from "@notesnook/core";
import * as openpgp from "openpgp";
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
export type DatabasePersistence = "memory" | "db";
@@ -133,8 +133,44 @@ export class NNStorage implements IStorage {
return await NNCrypto.exportKey(password, salt);
}
async generateCryptoKeyPair() {
return await NNCrypto.exportKeyPair();
async generatePGPKeyPair(): Promise<SerializedKeyPair> {
const keys = await openpgp.generateKey({
userIDs: [{ name: "NN", email: "NN@NN.NN" }]
});
return { publicKey: keys.publicKey, privateKey: keys.privateKey };
}
async validatePGPKeyPair(keys: SerializedKeyPair): Promise<boolean> {
try {
const dummyData = JSON.stringify({
favorite: true,
title: "Hello world"
});
const publicKey = await openpgp.readKey({ armoredKey: keys.publicKey });
const encrypted = await openpgp.encrypt({
message: await openpgp.createMessage({
text: dummyData
}),
encryptionKeys: publicKey
});
const message = await openpgp.readMessage({
armoredMessage: encrypted
});
const privateKey = await openpgp.readPrivateKey({
armoredKey: keys.privateKey
});
const decrypted = await openpgp.decrypt({
message,
decryptionKeys: privateKey
});
return decrypted.data === dummyData;
} catch (e) {
console.error("PGP key pair validation error:", e);
return false;
}
}
async hash(password: string, email: string): Promise<string> {
@@ -165,12 +201,21 @@ export class NNStorage implements IStorage {
return NNCrypto.decryptMulti(key, items, "text");
}
decryptAsymmetric(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<"base64">
async decryptPGPMessage(
privateKeyArmored: string,
encryptedMessage: string
): Promise<string> {
cipherData.format = "base64";
return NNCrypto.decryptAsymmetric(keyPair, cipherData, "base64");
const message = await openpgp.readMessage({
armoredMessage: encryptedMessage
});
const privateKey = await openpgp.readPrivateKey({
armoredKey: privateKeyArmored
});
const decrypted = await openpgp.decrypt({
message,
decryptionKeys: privateKey
});
return decrypted.data;
}
/**

View File

@@ -240,7 +240,7 @@ class EditorStore extends BaseStore<EditorStore> {
);
};
init = () => {
init = async () => {
useSettingStore.subscribe(
(s) => s.hideNoteTitle,
(state) => {
@@ -533,11 +533,11 @@ class EditorStore extends BaseStore<EditorStore> {
if (activeTabId) {
const tab = this.get().tabs.find((t) => t.id === activeTabId);
if (!tab) return;
rehydrateSession(tab.sessionId);
await rehydrateSession(tab.sessionId);
} else newSession();
};
private rehydrateSession = (sessionId: string) => {
private rehydrateSession = async (sessionId: string) => {
const { openSession, openDiffSession, getSession, activateSession } =
this.get();
@@ -549,9 +549,9 @@ class EditorStore extends BaseStore<EditorStore> {
if (!session || !session.needsHydration) return;
if (session.type === "diff")
openDiffSession(session.note.id, session.historySessionId);
return openDiffSession(session.note.id, session.historySessionId);
else
openSession(session.note.id, {
return openSession(session.note.id, {
force: true
});
};

View File

@@ -27,6 +27,8 @@ import { TimeFormat, DayFormat, WeekFormat } from "@notesnook/core";
import { Profile, TrashCleanupInterval } from "@notesnook/core";
import { showToast } from "../utils/toast";
import { ConfirmDialog } from "../dialogs/confirm";
import * as openpgp from "openpgp";
import { InboxPGPKeysDialog } from "../dialogs/inbox-pgp-keys-dialog";
export const HostIds = [
"API_HOST",
@@ -294,17 +296,14 @@ class SettingStore extends BaseStore<SettingStore> {
try {
if (isInboxEnabled) {
const inboxTokens = await db.inboxApiKeys.get();
if (inboxTokens && inboxTokens.length > 0) {
const ok = await ConfirmDialog.show({
title: "Disable Inbox API",
message:
"Disabling will revoke all existing API keys, they will no longer work. Are you sure?",
positiveButtonText: "Yes",
negativeButtonText: "No"
});
if (!ok) return;
}
const ok = await ConfirmDialog.show({
title: "Disable Inbox API",
message:
"Disabling will delete all your unsynced inbox items. Additionally, disabling will revoke all existing API keys, they will no longer work. Are you sure?",
positiveButtonText: "Yes",
negativeButtonText: "No"
});
if (!ok) return;
await db.user.discardInboxKeys();
this.set({ isInboxEnabled: false });
@@ -312,8 +311,10 @@ class SettingStore extends BaseStore<SettingStore> {
return;
}
await db.user.getInboxKeys();
this.set({ isInboxEnabled: true });
const ok = await InboxPGPKeysDialog.show({ keys: null });
if (ok) {
this.set({ isInboxEnabled: true });
}
} catch (e) {
if (e instanceof Error) {
showToast("error", e.message);

View File

@@ -133,10 +133,13 @@ export class Monographs {
})
};
const method = update ? http.patch.json : http.post.json;
const deviceId = await this.db.kv().read("deviceId");
const { id, datePublished } = await method(
`${Constants.API_HOST}/monographs?deviceId=${deviceId}`,
const method = update ? http.patch.json : http.post.json;
const url = update
? `${Constants.API_HOST}/monographs?deviceId=${deviceId}`
: `${Constants.API_HOST}/monographs/v2?deviceId=${deviceId}`;
const { id, datePublished, publishUrl } = await method(
url,
monograph,
token
);
@@ -147,7 +150,8 @@ export class Monographs {
title: monograph.title,
selfDestruct: monograph.selfDestruct,
datePublished: datePublished,
password: monograph.password
password: monograph.password,
publishUrl: publishUrl
});
return id;
}
@@ -196,16 +200,23 @@ export class Monographs {
return this.db.storage().decrypt(monographPasswordsKey, password);
}
async analytics(monographId: string): Promise<MonographAnalytics> {
async metadata(monographId: string): Promise<{
publishUrl: string;
analytics: MonographAnalytics;
}> {
try {
const token = await this.db.tokenManager.getAccessToken();
const analytics = (await http.get(
`${Constants.API_HOST}/monographs/${monographId}/analytics`,
const info = (await http.get(
`${Constants.API_HOST}/monographs/${monographId}/metadata`,
token
)) as MonographAnalytics;
return analytics;
)) as { publishUrl: string; analytics: MonographAnalytics };
return info;
} catch {
return { totalViews: 0 };
const monograph = await this.get(monographId);
return {
publishUrl: monograph?.publishUrl || "",
analytics: { totalViews: 0 }
};
}
}
}

View File

@@ -187,24 +187,11 @@ export async function handleInboxItems(
continue;
}
const decryptedKey = await db.storage().decryptAsymmetric(inboxKeys, {
alg: item.key.alg,
cipher: item.key.cipher,
format: "base64",
length: item.key.length
});
const decryptedItem = await db.storage().decrypt(
{ key: decryptedKey },
{
alg: item.alg,
iv: item.iv,
cipher: item.cipher,
format: "base64",
length: item.length,
salt: item.salt
}
);
const decryptedItem = await db
.storage()
.decryptPGPMessage(inboxKeys.privateKey, item.cipher);
const parsed = JSON.parse(decryptedItem) as ParsedInboxItem;
if (parsed.type !== "note") {
continue;
}

View File

@@ -58,8 +58,11 @@ export type SyncTransferItem = {
count: number;
};
export type SyncInboxItem = Omit<SyncItem, "format"> & {
key: Omit<Cipher<"base64">, "format" | "salt" | "iv">;
export type SyncInboxItem = {
id: string;
v: number;
cipher: string;
alg: string;
};
export type ParsedInboxItem = {

View File

@@ -501,7 +501,7 @@ class UserManager {
async getInboxKeys() {
return this.getUserKey("inboxKeys", {
generateKey: () => this.db.crypto().generateCryptoKeyPair(),
generateKey: () => this.db.crypto().generatePGPKeyPair(),
errorContext: "inbox encryption keys"
});
}
@@ -529,6 +529,21 @@ class UserManager {
await this.setUser({ ...user, inboxKeys: undefined });
}
async saveInboxKeys(keys: SerializedKeyPair) {
const userEncryptionKey = await this.getMasterKey();
if (!userEncryptionKey) return;
const updatePayload = {
inboxKeys: {
public: keys.publicKey,
private: await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(keys.privateKey))
}
};
await this.updateUser(updatePayload);
}
async sendVerificationEmail(newEmail?: string) {
const token = await this.tokenManager.getAccessToken();
if (!token) return;

View File

@@ -62,6 +62,7 @@ export class Monographs implements ICollection {
datePublished: merged.datePublished,
selfDestruct: merged.selfDestruct,
password: merged.password,
publishUrl: merged.publishUrl,
type: "monograph"
});
}

View File

@@ -38,7 +38,7 @@ import { HTMLRewriter } from "../utils/html-rewriter.js";
import { ContentBlock } from "../types.js";
import {
InternalLink,
isInternalLink,
isNoteLink,
parseInternalLink
} from "../utils/internal-link.js";
import { Element } from "domhandler";
@@ -215,7 +215,8 @@ export class Tiptap {
(node) =>
isTag(node) &&
node.tagName === "a" &&
isInternalLink(node.attribs.href)
isNoteLink(node.attribs.href) &&
parseInternalLink(node.attribs.href)?.type === "note"
)
);
}, document.childNodes).map((element) => {
@@ -250,9 +251,7 @@ export class Tiptap {
result.internalLinks.push(
...findAll(
(e) =>
e.tagName === "a" &&
!!e.attribs.href &&
e.attribs.href.startsWith("nn://"),
e.tagName === "a" && !!e.attribs.href && isNoteLink(e.attribs.href),
document.childNodes
)
.map((e) => parseInternalLink(e.attribs.href))

View File

@@ -425,6 +425,14 @@ export class NNMigrationProvider implements MigrationProvider {
.addColumn("title", "text")
.execute();
}
},
"a-2026-02-11": {
async up(db) {
await db.schema
.alterTable("monographs")
.addColumn("publishUrl", "text", COLLATE_NOCASE)
.execute();
}
}
};
}

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
AsymmetricCipher,
Cipher,
DataFormat,
SerializedKey,
@@ -63,10 +62,6 @@ export interface IStorage {
key: SerializedKey,
items: Cipher<"base64">[]
): Promise<string[]>;
decryptAsymmetric(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<"base64">
): Promise<string>;
deriveCryptoKey(credentials: SerializedKey): Promise<void>;
hash(
password: string,
@@ -75,7 +70,12 @@ export interface IStorage {
): Promise<string>;
getCryptoKey(): Promise<string | undefined>;
generateCryptoKey(password: string, salt?: string): Promise<SerializedKey>;
generateCryptoKeyPair(): Promise<SerializedKeyPair>;
generatePGPKeyPair(): Promise<SerializedKeyPair>;
decryptPGPMessage(
privateKeyArmored: string,
encryptedMessage: string
): Promise<string>;
validatePGPKeyPair(keys: SerializedKeyPair): Promise<boolean>;
generateCryptoKeyFallback(
password: string,

View File

@@ -507,6 +507,7 @@ export interface Monograph extends BaseItem<"monograph"> {
datePublished: number;
selfDestruct: boolean;
password?: Cipher<"base64">;
publishUrl?: string;
}
export type Match = {

View File

@@ -30,8 +30,8 @@ export class Crypto {
return await this.storage().generateCryptoKey(password);
}
async generateCryptoKeyPair() {
return await this.storage().generateCryptoKeyPair();
async generatePGPKeyPair() {
return await this.storage().generatePGPKeyPair();
}
}

View File

@@ -17,16 +17,24 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const InternalLinkTypes = ["note"] as const;
type InternalLinkType = (typeof InternalLinkTypes)[number];
export type InternalLink<T extends InternalLinkType = InternalLinkType> = {
const InternalLinkTypes = ["note", "notebook", "tag", "color"] as const;
export type InternalLinkType = (typeof InternalLinkTypes)[number];
export type NoteLink = BaseInternalLink<"note">;
export type NotebookLink = BaseInternalLink<"notebook">;
export type TagLink = BaseInternalLink<"tag">;
export type ColorLink = BaseInternalLink<"color">;
type BaseInternalLink<
T extends InternalLinkType = InternalLinkType,
TParams extends InternalLinkParams[T] = InternalLinkParams[T]
> = {
type: T;
id: string;
params?: Partial<InternalLinkParams[T]>;
params?: Partial<TParams>;
};
export type InternalLink = NoteLink | NotebookLink | TagLink | ColorLink;
export type InternalLinkWithOffset<
T extends InternalLinkType = InternalLinkType
> = InternalLink<T> & {
> = BaseInternalLink<T> & {
start: number;
end: number;
text: string;
@@ -34,6 +42,9 @@ export type InternalLinkWithOffset<
type InternalLinkParams = {
note: { blockId: string };
notebook: {};
tag: {};
color: {};
};
export function createInternalLink<T extends InternalLinkType>(
type: T,
@@ -71,7 +82,11 @@ export function parseInternalLink(link: string): InternalLink | undefined {
}
export function isInternalLink(link: string) {
return link && link.startsWith("nn://");
return link ? link.startsWith("nn://") : false;
}
export function isNoteLink(link: string) {
return link ? link.startsWith("nn://note/") : false;
}
function isValidInternalType(type: string): type is InternalLinkType {

View File

@@ -19,19 +19,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ISodium } from "@notesnook/sodium";
import KeyUtils, { base64_variants } from "./keyutils.js";
import {
Cipher,
Output,
DataFormat,
SerializedKey,
SerializedKeyPair,
AsymmetricCipher
} from "./types.js";
import { Cipher, Output, DataFormat, SerializedKey } from "./types.js";
export default class Decryption {
private static transformInput(
sodium: ISodium,
cipherData: Cipher<DataFormat> | AsymmetricCipher<DataFormat>
cipherData: Cipher<DataFormat>
): Uint8Array {
let input: Uint8Array | null = null;
if (
@@ -80,28 +73,6 @@ export default class Decryption {
) as Output<TOutputFormat>;
}
static decryptAsymmetric<TOutputFormat extends DataFormat>(
sodium: ISodium,
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<DataFormat>,
outputFormat: TOutputFormat = "text" as TOutputFormat
): Output<TOutputFormat> {
const input = this.transformInput(sodium, cipherData);
const plaintext = sodium.crypto_box_seal_open(
input,
sodium.from_base64(keyPair.publicKey),
sodium.from_base64(keyPair.privateKey)
);
return (
outputFormat === "base64"
? sodium.to_base64(plaintext, base64_variants.URLSAFE_NO_PADDING)
: outputFormat === "text"
? sodium.to_string(plaintext)
: plaintext
) as Output<TOutputFormat>;
}
static createStream(
sodium: ISodium,
header: string,

View File

@@ -31,8 +31,7 @@ import {
DataFormat,
SerializedKey,
SerializedKeyPair,
EncryptionKeyPair,
AsymmetricCipher
EncryptionKeyPair
} from "./types.js";
export class NNCrypto implements INNCrypto {
@@ -98,20 +97,6 @@ export class NNCrypto implements INNCrypto {
return decryptedItems;
}
async decryptAsymmetric<TOutputFormat extends DataFormat>(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<DataFormat>,
outputFormat: TOutputFormat = "text" as TOutputFormat
): Promise<Output<TOutputFormat>> {
await this.init();
return Decryption.decryptAsymmetric(
this.sodium,
keyPair,
cipherData,
outputFormat
);
}
async hash(password: string, salt: string): Promise<string> {
await this.init();
return Password.hash(this.sodium, password, salt);

View File

@@ -26,8 +26,7 @@ import {
Output,
Input,
EncryptionKeyPair,
SerializedKeyPair,
AsymmetricCipher
SerializedKeyPair
} from "./types.js";
export interface IStreamable {
@@ -62,12 +61,6 @@ export interface INNCrypto {
outputFormat?: TOutputFormat
): Promise<Output<TOutputFormat>[]>;
decryptAsymmetric<TOutputFormat extends DataFormat>(
keyPair: SerializedKeyPair,
cipherData: AsymmetricCipher<DataFormat>,
outputFormat?: TOutputFormat
): Promise<Output<TOutputFormat>>;
hash(password: string, salt: string): Promise<string>;
deriveKey(password: string, salt?: string): Promise<EncryptionKey>;

View File

@@ -30,11 +30,6 @@ export type Cipher<TFormat extends DataFormat> = {
length: number;
};
export type AsymmetricCipher<TFormat extends DataFormat> = Omit<
Cipher<TFormat>,
"iv" | "salt"
>;
export type Output<TFormat extends DataFormat> =
TFormat extends StringOutputFormat ? string : Uint8Array;
export type Input<TFormat extends DataFormat> = Output<TFormat>;

View File

@@ -68,7 +68,7 @@
"@notesnook/intl": "file:../intl",
"@notesnook/theme": "file:../theme",
"@notesnook/ui": "file:../ui",
"@social-embed/lib": "^0.1.0-next.7",
"@social-embed/lib": "^0.1.0-next.11",
"@tiptap/core": "2.6.6",
"@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6",

View File

@@ -162,6 +162,11 @@ const Tiptap = ({
attachment
) as Promise<string | undefined>;
},
getLinkData: (url: string) => {
return postAsyncWithTimeout(EditorEvents.getLinkData, {
url: url
});
},
createInternalLink(attributes) {
return postAsyncWithTimeout(EditorEvents.createInternalLink, {
attributes

View File

@@ -55,5 +55,6 @@ export const EditorEvents = {
goForward: "editor-events:go-forward",
saveScroll: "editor-events:save-scroll",
newNote: "editor-events:new-note",
downloadCsv: "editor-events:download-csv"
downloadCsv: "editor-events:download-csv",
getLinkData: "editor-events:get-link-data"
} as const;

View File

@@ -88,12 +88,14 @@ import { strings } from "@notesnook/intl";
import { InlineCode } from "./extensions/inline-code/inline-code.js";
import { FontLigature } from "./extensions/font-ligature/font-ligature.js";
import { SearchResult } from "./extensions/search-result/search-result.js";
import { LinkData } from "./types.js";
interface TiptapStorage {
dateFormat?: DateTimeOptions["dateFormat"];
timeFormat?: DateTimeOptions["timeFormat"];
dayFormat?: DateTimeOptions["dayFormat"];
openLink?: (url: string, openInNewTab?: boolean) => void;
getLinkData?: (url: string) => Promise<LinkData | undefined>;
downloadAttachment?: (attachment: Attachment) => void;
openAttachmentPicker?: (type: AttachmentType) => void;
previewAttachment?: (attachment: Attachment) => void;
@@ -148,6 +150,7 @@ const useTiptap = (
openAttachmentPicker,
previewAttachment,
openLink,
getLinkData,
onBeforeCreate,
dateFormat,
timeFormat,
@@ -285,7 +288,13 @@ const useTiptap = (
}).configure({
openOnClick: !isMobile,
autolink: false,
linkOnPaste: true
linkOnPaste: true,
protocols: [
{
scheme: "nn",
optionalSlashes: true
}
]
}),
Table.configure({
resizable: true,
@@ -395,6 +404,7 @@ const useTiptap = (
editor.storage.createInternalLink = createInternalLink;
editor.storage.getAttachmentData = getAttachmentData;
editor.storage.downloadCsvTable = downloadCsvTable;
editor.storage.getLinkData = getLinkData;
if (onBeforeCreate) onBeforeCreate({ editor });
},

View File

@@ -19,20 +19,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ToolProps } from "../types.js";
import { ToolButton } from "../components/tool-button.js";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ResponsivePresenter } from "../../components/responsive/index.js";
import { LinkPopup } from "../popups/link-popup.js";
import { useToolbarLocation } from "../stores/toolbar-store.js";
import { MoreTools } from "../components/more-tools.js";
import { useRefValue } from "../../hooks/use-ref-value.js";
import { findMark, selectionToOffset } from "../../utils/prosemirror.js";
import { Flex, Link } from "@theme-ui/components";
import { Flex, Link, Text } from "@theme-ui/components";
import { ImageNode } from "../../extensions/image/index.js";
import { Link as LinkNode } from "../../extensions/link/index.js";
import { getMarkAttributes } from "@tiptap/core";
import { useHoverPopupContext } from "../floating-menus/hover-popup/context.js";
import { strings } from "@notesnook/intl";
import { find } from "linkifyjs";
import { Icons } from "../icons.js";
import { mdiNoteOutline, mdiBookOutline, mdiPound } from "@mdi/js";
import { Icon } from "@notesnook/ui";
import { LinkData } from "../../types.js";
export function LinkSettings(props: ToolProps) {
const { editor } = props;
@@ -204,36 +208,62 @@ export function OpenLink(props: ToolProps) {
);
const { node } = selectedNode.current || {};
const link = node ? findMark(node, "link") : null;
if (!link) return null;
const href = link?.attrs.href;
if (!href) return null;
const href = link?.attrs.href ?? null;
const [loading, setLoading] = useState(false);
const [linkData, setLinkData] = useState<LinkData | undefined>(undefined);
useEffect(() => {
if (!href) return;
(async () => {
try {
setLoading(true);
const result = await editor.storage.getLinkData?.(href);
setLinkData(result);
setLoading(false);
} catch (e) {
setLoading(false);
}
})();
}, [href]);
if (!link || !href) return null;
const title = linkData?.title || href;
return (
<Flex sx={{ alignItems: "center" }}>
<Link
href={href}
onClick={(e) => {
e.preventDefault();
editor.storage.openLink?.(href);
hide();
}}
target="_blank"
variant="body"
sx={{
fontSize: "subBody",
fontFamily: "body",
mr: 1,
color: "accent",
maxWidth: [150, 250],
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
":visited": { color: "accent" },
":hover": { color: "accent", opacity: 0.8 }
}}
>
{href}
</Link>
{linkData?.type && (
<LinkTypeIcon type={linkData.type} metadata={linkData.metadata} />
)}
{loading ? (
<Text sx={{ fontSize: "subBody" }}>{strings.loading()}</Text>
) : (
<Link
href={href}
onClick={(e) => {
e.preventDefault();
editor.storage.openLink?.(href);
hide();
}}
target="_blank"
variant="body"
sx={{
fontSize: "subBody",
fontFamily: "body",
mr: 4,
color: "accent",
maxWidth: [150, 250],
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
":visited": { color: "accent" },
":hover": { color: "accent", opacity: 0.8 }
}}
>
{title}
</Link>
)}
<ToolButton
icon={props.icon}
title={props.title}
@@ -335,6 +365,31 @@ function LinkTool(props: LinkToolProps) {
);
}
const LINK_TYPE_ICONS: Record<LinkData["type"], string> = {
note: mdiNoteOutline,
notebook: mdiBookOutline,
tag: mdiPound,
color: Icons.circle
};
function LinkTypeIcon({
type,
metadata
}: {
type: LinkData["type"];
metadata?: LinkData["metadata"];
}) {
const path = LINK_TYPE_ICONS[type];
if (!path) return null;
const color =
type === "color" && metadata?.colorCode
? metadata.colorCode
: "icon-secondary";
return <Icon path={path} color={color} size={13} sx={{ mr: 1 }} />;
}
export function isInternalLink(href?: string | null) {
return typeof href === "string" ? href.startsWith("nn://") : false;
}

View File

@@ -25,6 +25,12 @@ export type PermissionRequestEvent = CustomEvent<{
silent: boolean;
}>;
export type LinkData = {
type: "note" | "notebook" | "color" | "tag";
title?: string;
metadata?: Record<string, string | undefined>;
};
export class Editor extends TiptapEditor {
private mutex: Mutex = new Mutex();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -909,6 +909,8 @@ $day$: Current day (eg. Monday)`,
history: () => t`History`,
copyLink: () => t`Copy link`,
linkCopied: () => t`Link copied`,
copyId: () => t`Copy ID`,
idCopied: () => t`ID copied`,
readOnly: () => t`Read only`,
syncOff: () => t`Sync off`,
syncOffConfirm: (count: number) =>
@@ -2636,5 +2638,66 @@ Use this if changes from other devices are not appearing on this device. This wi
deleteData: () => t`Delete data`,
failedToAttachFile: () => t`Failed to attach file`,
unlockNoteToMergeConflicts: () => t`Unlock note to merge conflicts`,
confirmationEmailSent: () => t`Confirmation email sent`
confirmationEmailSent: () => t`Confirmation email sent`,
inboxAPI: () => t`Inbox API`,
inboxAPIDesc: () =>
t`Share things to Notesbook from anywhere using the Inbox API`,
enableInboxAPI: () => t`Enable Inbox API`,
enableInboxAPIDesc: () => t`Enable/Disable Inbox API`,
manageInboxKeys: () => t`Inbox Keys`,
manageInboxKeysDesc: () =>
t`View and edit your inbox public/private key pair`,
disableInboxAPI: () => t`Disable Inbox API`,
disableInboxAPIDesc: () =>
t`Disabling will delete all your unsynced inbox items. Additionally, disabling will revoke all existing API keys, they will no longer work. Are you sure?`,
addKey: () => t`Add key`,
viewAPIKeys: () => t`API Keys`,
viewAPIKeysDesc: () => t`View and manage inbox API keys`,
createApiKey: () => t`Create API Key`,
keyName: () => t`Key name`,
exampleKeyName: () => t`e.g., Todo integration`,
expiresIn: () => t`Expires in`,
enterKeyName: () => t`Please enter a key name`,
apiKeyCreatedSuccessfully: () => t`API key created successfully`,
failedToCreateApiKey: (message: string) =>
t`Failed to create API key${message ? `: ${message}` : ""}`,
creating: () => t`Creating...`,
expiryOneDay: () => t`1 day`,
expiryOneWeek: () => t`1 week`,
expiryOneMonth: () => t`1 month`,
expiryOneYear: () => t`1 year`,
loadingApiKeys: () => t`Loading API keys...`,
failedToLoadApiKeys: () => t`Failed to load API keys. Please try again.`,
retry: () => t`Retry`,
createFirstApiKey: () => t`Create your first api key to get started.`,
createKey: () => t`Create Key`,
ok: () => t`OK`,
apiKeysLimitReached: () => t`API Keys Limit Reached`,
apiKeysLimitReachedMessage: () =>
t`Cannot create more than 10 api keys at a time. Please revoke some existing keys before creating new ones.`,
authenticateToViewApiKey: () => t`Authenticate to view API key`,
enterPasswordToViewApiKey: () =>
t`Please enter your account password to view this API key.`,
authenticate: () => t`Authenticate`,
invalidPassword: () => t`Invalid password`,
apiKeyCopiedToClipboard: () => t`API key copied to clipboard`,
failedToCopyToClipboard: () => t`Failed to copy to clipboard`,
revokeInboxApiKey: (name: string) => t`Revoke Inbox API Key - ${name}`,
revokeApiKeyConfirmation: (name: string) =>
t`Are you sure you want to revoke the key "${name}"? All inbox actions using this key will stop working immediately.`,
apiKeyRevoked: () => t`API key revoked`,
failedToRevokeApiKey: () => t`Failed to revoke API key`,
lastUsedOn: () => t`Last used on`,
neverUsed: () => t`Never used`,
createdOn: () => t`Created on`,
neverExpires: () => t`Never expires`,
expired: () => t`Expired`,
expiresOn: () => t`Expires on`,
changingInboxPgpKeysNotice: () =>
t`Changing Inbox PGP keys will delete all your unsynced inbox items.`,
publicKey: () => t`Public Key:`,
privateKey: () => t`Private Key:`,
invalidPgpKeyPair: () =>
t`Invalid PGP key pair. Please check your keys and try again.`,
inboxKeysSaved: () => t`Inbox keys saved`
};

View File

@@ -118,12 +118,6 @@ export class Sodium implements ISodium {
get crypto_secretstream_xchacha20poly1305_TAG_MESSAGE() {
return sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
}
get crypto_box_keypair() {
return sodium.crypto_box_keypair;
}
get crypto_box_seal_open() {
return sodium.crypto_box_seal_open;
}
}
function convertVariant(variant: base64_variants): sodium.base64_variants {

View File

@@ -46,12 +46,7 @@ import {
crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
crypto_secretstream_xchacha20poly1305_TAG_FINAL,
crypto_secretstream_xchacha20poly1305_TAG_MESSAGE,
crypto_box_keypair as sodium_native_crypto_box_keypair,
crypto_box_PUBLICKEYBYTES,
crypto_box_SECRETKEYBYTES,
crypto_box_seal_open as sodium_native_crypto_box_seal_open,
crypto_box_SEALBYTES
crypto_secretstream_xchacha20poly1305_TAG_MESSAGE
} from "sodium-native";
import { Buffer } from "node:buffer";
import { base64_variants, ISodium } from "./types";
@@ -346,71 +341,6 @@ function crypto_secretstream_xchacha20poly1305_pull(
return { message, tag: tag.readUInt8() } as MessageTag | StringMessageTag;
}
function crypto_box_keypair(
outputFormat?: Uint8ArrayOutputFormat | null
): KeyPair;
function crypto_box_keypair(outputFormat: StringOutputFormat): StringKeyPair;
function crypto_box_keypair(
outputFormat?: Uint8ArrayOutputFormat | null | StringOutputFormat
): KeyPair | StringKeyPair {
const publicBuffer = Buffer.alloc(crypto_box_PUBLICKEYBYTES);
const privateBuffer = Buffer.alloc(crypto_box_SECRETKEYBYTES);
sodium_native_crypto_box_keypair(publicBuffer, privateBuffer);
if (typeof outputFormat === "string") {
const transformer =
outputFormat === "base64"
? to_base64
: outputFormat === "hex"
? to_hex
: to_string;
return {
keyType: "x25519" as KeyType,
publicKey: transformer(new Uint8Array(publicBuffer)),
privateKey: transformer(new Uint8Array(privateBuffer))
};
} else {
return {
keyType: "x25519" as KeyType,
publicKey: new Uint8Array(publicBuffer),
privateKey: new Uint8Array(privateBuffer)
};
}
}
function crypto_box_seal_open(
ciphertext: string | Uint8Array,
publicKey: Uint8Array,
privateKey: Uint8Array,
outputFormat?: Uint8ArrayOutputFormat | null
): Uint8Array;
function crypto_box_seal_open(
ciphertext: string | Uint8Array,
publicKey: Uint8Array,
privateKey: Uint8Array,
outputFormat: StringOutputFormat
): string;
function crypto_box_seal_open(
ciphertext: string | Uint8Array,
publicKey: Uint8Array,
privateKey: Uint8Array,
outputFormat?: StringOutputFormat | Uint8ArrayOutputFormat | null
): string | Uint8Array {
const cipher = toBuffer(ciphertext);
return wrap(
cipher.byteLength - crypto_box_SEALBYTES,
(message) =>
sodium_native_crypto_box_seal_open(
message,
cipher,
toBuffer(publicKey),
toBuffer(privateKey)
),
outputFormat
);
}
function randombytes_buf(
length: number,
outputFormat?: Uint8ArrayOutputFormat | null
@@ -473,10 +403,6 @@ function to_string(input: Uint8Array): string {
);
}
function to_hex(input: Uint8Array): string {
return Buffer.from(input, input.byteOffset, input.byteLength).toString("hex");
}
type ToBufferInput = string | Uint8Array | null | undefined;
type ToBufferResult<TInput extends ToBufferInput> = TInput extends
| undefined
@@ -625,12 +551,6 @@ export class Sodium implements ISodium {
get crypto_secretstream_xchacha20poly1305_TAG_MESSAGE() {
return crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
}
get crypto_box_keypair() {
return crypto_box_keypair;
}
get crypto_box_seal_open() {
return crypto_box_seal_open;
}
}
export { base64_variants, type ISodium };

View File

@@ -61,6 +61,4 @@ export interface ISodium {
get crypto_aead_xchacha20poly1305_ietf_NPUBBYTES(): typeof sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES;
get crypto_secretstream_xchacha20poly1305_TAG_FINAL(): typeof sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL;
get crypto_secretstream_xchacha20poly1305_TAG_MESSAGE(): typeof sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
get crypto_box_keypair(): typeof sodium.crypto_box_keypair;
get crypto_box_seal_open(): typeof sodium.crypto_box_seal_open;
}