mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-30 02:29:18 +02:00
Compare commits
39 Commits
fix/289
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d36fe7022 | ||
|
|
614046f8c2 | ||
|
|
23bb62c88d | ||
|
|
b58db58e13 | ||
|
|
195dc18a09 | ||
|
|
187086460d | ||
|
|
84655cec40 | ||
|
|
49b1dd46d1 | ||
|
|
76e14d4434 | ||
|
|
f828ab0314 | ||
|
|
8d3d173762 | ||
|
|
12644fce28 | ||
|
|
358b499d32 | ||
|
|
17864d3325 | ||
|
|
c90a2f5179 | ||
|
|
47b2569b29 | ||
|
|
692975d4a0 | ||
|
|
6a843a6457 | ||
|
|
a139bdad1e | ||
|
|
b5dc298d7d | ||
|
|
561477fb1a | ||
|
|
543766d564 | ||
|
|
9789edad4c | ||
|
|
b46e02753a | ||
|
|
5f74fcad0c | ||
|
|
34b5005591 | ||
|
|
2b0a1c6a78 | ||
|
|
fcba5f9c91 | ||
|
|
5909025976 | ||
|
|
c2702efc7f | ||
|
|
c96d13d416 | ||
|
|
3d52a307ca | ||
|
|
32c825463f | ||
|
|
1172a8c67b | ||
|
|
0191e5be00 | ||
|
|
7f367389bb | ||
|
|
e94c757d5b | ||
|
|
29e213ebe2 | ||
|
|
8eb7663f37 |
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
169
apps/mobile/app/components/sheets/add-api-key/index.tsx
Normal file
169
apps/mobile/app/components/sheets/add-api-key/index.tsx
Normal 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} />
|
||||
)
|
||||
});
|
||||
};
|
||||
@@ -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} />
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"]?.(
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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 />
|
||||
};
|
||||
|
||||
474
apps/mobile/app/screens/settings/manage-inbox-keys.tsx
Normal file
474
apps/mobile/app/screens/settings/manage-inbox-keys.tsx
Normal 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 };
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Binary file not shown.
@@ -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", {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ShareMedia</string>
|
||||
<string>nn</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
|
||||
31
apps/mobile/package-lock.json
generated
31
apps/mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
67
apps/monograph/app/components/monograph-view.tsx
Normal file
67
apps/monograph/app/components/monograph-view.tsx
Normal 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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -28,4 +28,5 @@ export type Monograph = {
|
||||
encryptedContent?: Cipher<"base64">;
|
||||
datePublished: string;
|
||||
id: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
77
apps/monograph/app/routes/s.$slug.tsx
Normal file
77
apps/monograph/app/routes/s.$slug.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
|
||||
40
apps/monograph/package-lock.json
generated
40
apps/monograph/package-lock.json
generated
@@ -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"
|
||||
|
||||
1422
apps/web/package-lock.json
generated
1422
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,5 +41,8 @@ export const AppEvents = {
|
||||
revealItemInList: "list:revealItem",
|
||||
|
||||
toggleSideMenu: "app:openSideMenu",
|
||||
toggleEditor: "app:toggleEditor"
|
||||
toggleEditor: "app:toggleEditor",
|
||||
|
||||
onOpenLink: "onOpenLink",
|
||||
onCreateItem: "onCreateItem"
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || ""
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
210
apps/web/src/dialogs/inbox-pgp-keys-dialog.tsx
Normal file
210
apps/web/src/dialogs/inbox-pgp-keys-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -62,6 +62,7 @@ export class Monographs implements ICollection {
|
||||
datePublished: merged.datePublished,
|
||||
selfDestruct: merged.selfDestruct,
|
||||
password: merged.password,
|
||||
publishUrl: merged.publishUrl,
|
||||
type: "monograph"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -507,6 +507,7 @@ export interface Monograph extends BaseItem<"monograph"> {
|
||||
datePublished: number;
|
||||
selfDestruct: boolean;
|
||||
password?: Cipher<"base64">;
|
||||
publishUrl?: string;
|
||||
}
|
||||
|
||||
export type Match = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
2
packages/editor-mobile/package-lock.json
generated
2
packages/editor-mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user