mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-30 10:39:07 +02:00
Compare commits
91 Commits
fix-loadin
...
fix/read-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae751cdd65 | ||
|
|
0d00bb2866 | ||
|
|
4299965064 | ||
|
|
cf87929476 | ||
|
|
d343a0720e | ||
|
|
51bd58edb2 | ||
|
|
f778d83316 | ||
|
|
b169206c68 | ||
|
|
4cea69e71d | ||
|
|
04d422b411 | ||
|
|
49dec52aa0 | ||
|
|
b83a62814d | ||
|
|
ce5449f25a | ||
|
|
aa769a8921 | ||
|
|
a8d4e6c754 | ||
|
|
f2402cf086 | ||
|
|
07a481f002 | ||
|
|
f41638db6d | ||
|
|
82baf41d8e | ||
|
|
e60370d4dc | ||
|
|
3e412323ee | ||
|
|
d635c1a056 | ||
|
|
f02db2a024 | ||
|
|
8c74324ff8 | ||
|
|
f2230eb39b | ||
|
|
ab900ae5da | ||
|
|
1cf28c56c2 | ||
|
|
08cd33a0cf | ||
|
|
7b0d2a239d | ||
|
|
5d922d934c | ||
|
|
5bab9ea914 | ||
|
|
da06d7e636 | ||
|
|
aece140473 | ||
|
|
656f87e3c6 | ||
|
|
36a95616d9 | ||
|
|
60bdc4715d | ||
|
|
7da168ab52 | ||
|
|
b20ad48b31 | ||
|
|
8b17495f0e | ||
|
|
e113e1b59c | ||
|
|
6bd4924c0b | ||
|
|
a2939aa183 | ||
|
|
effa726b65 | ||
|
|
20d9bf1f23 | ||
|
|
09e5d014df | ||
|
|
add1b8a68a | ||
|
|
fe143f66b7 | ||
|
|
75c8ca29ef | ||
|
|
c52779df23 | ||
|
|
b7b77646e6 | ||
|
|
1157c6b7cd | ||
|
|
ab824d7f66 | ||
|
|
702e28a6e6 | ||
|
|
0e0ab58959 | ||
|
|
e5cdc9ea3d | ||
|
|
d3c9744e65 | ||
|
|
6b02144b30 | ||
|
|
91cbc5ddf0 | ||
|
|
93aa1ca5aa | ||
|
|
6ae13ebb2f | ||
|
|
73cdbf64c7 | ||
|
|
0aeea965c0 | ||
|
|
a63b15637f | ||
|
|
5153b43bc6 | ||
|
|
9ea7d09722 | ||
|
|
b7b474b245 | ||
|
|
e2184a8bdf | ||
|
|
9ea84ca9ae | ||
|
|
50136e3689 | ||
|
|
e87f5e5f89 | ||
|
|
d44792d132 | ||
|
|
50b01e0b1b | ||
|
|
122c3a96c7 | ||
|
|
4d407bd646 | ||
|
|
16b6b37b94 | ||
|
|
8c766ef4f0 | ||
|
|
b7d2865435 | ||
|
|
4a158b2547 | ||
|
|
e381e54bb7 | ||
|
|
7001f98353 | ||
|
|
a836642d74 | ||
|
|
a3d3add48f | ||
|
|
994060cce2 | ||
|
|
75103dc706 | ||
|
|
cf63c2fdc5 | ||
|
|
f15b4cf634 | ||
|
|
6430611e3e | ||
|
|
afe57e41ce | ||
|
|
86f3f90143 | ||
|
|
d3ea51cc01 | ||
|
|
94dbec4486 |
4
apps/desktop/package-lock.json
generated
4
apps/desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.8",
|
||||
"version": "3.3.9-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.8",
|
||||
"version": "3.3.9-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@notesnook/desktop",
|
||||
"productName": "Notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "3.3.8",
|
||||
"version": "3.3.9-beta.2",
|
||||
"appAppleId": "1544027013",
|
||||
"private": true,
|
||||
"main": "./dist/cjs/index.js",
|
||||
|
||||
@@ -113,7 +113,8 @@ export class SQLite {
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) e.message += ` (query: ${sql})`;
|
||||
if (e instanceof Error)
|
||||
throw rewriteError(e, `${e.message} (query: ${sql})`);
|
||||
throw e;
|
||||
} finally {
|
||||
// Since SQLite 3.48.0 (SQLite3MC v2.0.2) it's not possible to load fts5
|
||||
@@ -212,3 +213,11 @@ function getExtensionPath(extensionName: string, entryPoint: string) {
|
||||
}
|
||||
return loadablePath;
|
||||
}
|
||||
|
||||
function rewriteError(e: Error, message: string) {
|
||||
const error = new Error(message);
|
||||
error.stack = e.stack;
|
||||
error.name = e.name;
|
||||
error.cause = e.cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
</p>
|
||||
|
||||
<h1 align="center">Notesnook Mobile</h1>
|
||||
<h3 align="center">The mobile app is built using React Native, Typescript & Javascript for both iOS & Android.</h3>
|
||||
<p align="center">
|
||||
<a href="#developer-guide">Developer guide</a> | <a href="#build-instructions">How to build?</a>
|
||||
</p>
|
||||
<h3 align="center">The mobile app is built with React Native for both iOS and Android.</h3>
|
||||
<p align="center"><a href="#build-instructions">Build instructions</a> | <a href="#developer-guide">Developer guide</a> | <a href="#running-e2e-tests-detox">E2E tests</a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://play.google.com/store/apps/details?id=com.streetwriters.notesnook">
|
||||
@@ -25,21 +23,21 @@
|
||||
|
||||
Requirements:
|
||||
|
||||
1. [Node.js](https://nodejs.org/en/download/)
|
||||
1. [Node.js](https://nodejs.org/en/download/) 20+ (the repo is pinned to Node `22.20.0` via Volta)
|
||||
2. [git](https://git-scm.com/downloads)
|
||||
3. NPM (not yarn or pnpm)
|
||||
4. [React Native](https://reactnative.dev/docs/environment-setup)
|
||||
3. `npm`
|
||||
4. [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment)
|
||||
|
||||
To run the app locally, you will need to setup React Native on your system:
|
||||
To run the app locally, first complete React Native native tooling setup:
|
||||
|
||||
1. Open the official [environment setup guide here](https://reactnative.dev/docs/environment-setup)
|
||||
1. Open [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment)
|
||||
2. Select `React Native CLI Quickstart`
|
||||
3. Select your OS & the platform to run the app on (iOS or Android)
|
||||
3. Select your OS and target platform(s): iOS and/or Android
|
||||
4. Follow the steps listed.
|
||||
|
||||
> Please keep in mind that **Expo is not supported**.
|
||||
> Expo is not used in this project.
|
||||
|
||||
Once you have completed the setup, the first step is to `clone` the monorepo:
|
||||
Clone the monorepo:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/streetwriters/notesnook.git
|
||||
@@ -48,26 +46,27 @@ git clone https://github.com/streetwriters/notesnook.git
|
||||
cd notesnook
|
||||
```
|
||||
|
||||
Once you are inside the `./notesnook` directory, run the preparation step:
|
||||
Install dependencies and bootstrap the mobile workspace:
|
||||
|
||||
```bash
|
||||
# this might take a while to complete
|
||||
npm install
|
||||
npm run bootstrap -- --scope=mobile
|
||||
```
|
||||
|
||||
### Running the app on Android
|
||||
|
||||
[Setup an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) if you haven't already, and then run the following command to start the app in the Emulator:
|
||||
[Set up an Android emulator from Android Studio](https://developer.android.com/studio/run/managing-avds) (or connect a physical device), then run:
|
||||
|
||||
```bash
|
||||
npm run start:android
|
||||
```
|
||||
|
||||
If you want to run the app on your phone, make sure to [enable USB debugging](https://developer.android.com/studio/debug/dev-options).
|
||||
If you are using a physical device, enable [USB debugging](https://developer.android.com/studio/debug/dev-options).
|
||||
|
||||
### Running the app on iOS
|
||||
|
||||
To run the app on iOS:
|
||||
Install CocoaPods dependencies first, then run the iOS app:
|
||||
|
||||
```bash
|
||||
# this might take a while to complete
|
||||
@@ -76,90 +75,87 @@ npm run prepare:ios
|
||||
npm run start:ios
|
||||
```
|
||||
|
||||
### Useful development commands
|
||||
|
||||
```bash
|
||||
# start Metro only
|
||||
npm run start:metro
|
||||
|
||||
# start Re.Pack bundler
|
||||
npm run start:repack
|
||||
```
|
||||
|
||||
## Developer guide
|
||||
|
||||
> This project is in a transition state between Javascript & Typescript. We are gradually porting everything over to Typescript, so if you can help with that, it'd be great!
|
||||
> The mobile app is a mixed TypeScript/JavaScript codebase.
|
||||
|
||||
### The tech stack
|
||||
|
||||
We try to keep the stack as lean as possible:
|
||||
|
||||
1. React Native
|
||||
2. Typescript/Javascript
|
||||
3. Zustand: State management
|
||||
4. Detox: Runs all our e2e tests
|
||||
5. React Native MMKV: Database & persistence
|
||||
6. libsodium: Encryption
|
||||
1. React Native `0.82`
|
||||
2. React `19`
|
||||
3. TypeScript + JavaScript
|
||||
4. Zustand (state management)
|
||||
5. Detox (end-to-end testing)
|
||||
6. libsodium (encryption)
|
||||
|
||||
### Project structure
|
||||
|
||||
The app codebase is distributed over two primary directories. `native/` and `app/`.
|
||||
Top-level directories in `apps/mobile/`:
|
||||
|
||||
- `native/`: Includes `android/` and `ios/` folders and everything related to react native core functionality like bundling, development, and packaging. Any react-native dependency with native code, i.e., android & ios folders, is installed here.
|
||||
- `app/`: Main React Native app source (`components`, `common`, `hooks`, `navigation`, `screens`, `services`, `stores`, `utils`, etc.)
|
||||
- `android/`: Android native project
|
||||
- `ios/`: iOS native project
|
||||
- `e2e/`: Detox test suite and config
|
||||
- `patches/`: `patch-package` patches
|
||||
- `scripts/`: Mobile-specific scripts
|
||||
|
||||
- `app/`: Includes all the app code other than the native part. All JS-only dependencies are installed here.
|
||||
- `components/`: Each component serves a specific purpose in the app UI. For example, the `Paragraph` component is used to render paragraphs in the app, and a `Header` component is used to render a `header` on all screens.
|
||||
- `common/`: Features that are integral to the app's functionality. For example, the notesnook core is initialized here.
|
||||
- `hooks/`: Hooks for different app logic
|
||||
- `navigation/`: Includes app navigation-specific code. Here the app navigation, editor & side menu are rendered side by side in fluid tabs.
|
||||
- `screens`: Navigator screens.
|
||||
- `services`: Parts of code that do a specific function. For example, the `sync` service runs Sync from anywhere in the app.
|
||||
- `stores`: We use `zustand` for global state management in the app. Multiple stores provide the state for different parts of the app.
|
||||
- `utils`: General purpose stuff such as constant values, utility functions, etc.
|
||||
## Running E2E tests (Detox)
|
||||
|
||||
There are several other folders at the root:
|
||||
Detox device defaults in this repo:
|
||||
|
||||
- `share/`: Code for the iOS Share Extension and Android widget.
|
||||
- `e2e/`: Detox End to end tests
|
||||
- `patches/`: Patches for various react native dependencies.
|
||||
|
||||
### Running the tests
|
||||
|
||||
When you are done making the required changes, you must run the tests to ensure you didn't break anything. We use Detox as the testing framework & the tests can be started as follows:
|
||||
- Android emulator: `Pixel_5_API_36`
|
||||
- iOS simulator: `iPhone 17 Pro Max`
|
||||
|
||||
### Android
|
||||
|
||||
To run the tests on Android, you will need to create an emulator device on your system:
|
||||
Build and run Android Detox tests:
|
||||
|
||||
```
|
||||
$ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_5_API_31 -d pixel --package "system-images;android-31;default;x86_64"
|
||||
```
|
||||
|
||||
If you face problems, follow the detailed guide in [Detox documentation](https://wix.github.io/Detox/docs/introduction/android-dev-env). Keep the emulator name set to `Pixel_5_API_31`.
|
||||
|
||||
Once you have created an emulator device, build the Android apks:
|
||||
|
||||
```
|
||||
```bash
|
||||
npm run build:android
|
||||
```
|
||||
|
||||
Finally, run the tests:
|
||||
|
||||
```
|
||||
npm run test:android
|
||||
```
|
||||
|
||||
For debug configuration:
|
||||
|
||||
```bash
|
||||
npm run build:android:debug
|
||||
npm run start:metro
|
||||
npm run test:android:debug
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
To run e2e tests on the iOS simulator, you must be on a Mac with XCode installed.
|
||||
|
||||
First, install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils):
|
||||
Build and run iOS Detox tests:
|
||||
|
||||
```bash
|
||||
npm run build:ios
|
||||
npm run test:ios
|
||||
```
|
||||
|
||||
If simulator tooling is missing, install [AppleSimulatorUtils](https://github.com/wix/AppleSimulatorUtils):
|
||||
|
||||
```bash
|
||||
brew tap wix/brew
|
||||
brew install applesimutils
|
||||
```
|
||||
|
||||
Now build the iOS app for testing:
|
||||
## Release commands
|
||||
|
||||
```
|
||||
npm run build:ios
|
||||
```
|
||||
|
||||
Finally, run the tests:
|
||||
Android release helpers:
|
||||
|
||||
```bash
|
||||
npm run release:android
|
||||
npm run release:android:bundle
|
||||
```
|
||||
npm run test:ios
|
||||
```
|
||||
|
||||
All tests on iOS are configured to run on `iPhone 8` simulator.
|
||||
|
||||
@@ -125,7 +125,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
multiDexEnabled true
|
||||
versionCode 3091
|
||||
versionCode 3094
|
||||
versionName getNpmVersion()
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
- Add notebooks, tags and colors to Home Screen Shortcuts
|
||||
- Change day format and use /day in notes
|
||||
- Add Setting to change default editor line height
|
||||
- Set a custom title for monographs
|
||||
- Add webpage title and date clipped to web clips
|
||||
- Configure Week to start from Sunday or Monday
|
||||
- Change Note's creation date
|
||||
- Set expiry date on notes
|
||||
- Temporarily disable password change and recovery options
|
||||
- Note history now includes note title
|
||||
- Minor bug fixes
|
||||
- Bug fixes and minor improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
getCacheSize,
|
||||
hashBase64,
|
||||
readEncrypted,
|
||||
writeEncryptedBase64
|
||||
writeEncryptedBase64,
|
||||
bulkDeleteFiles
|
||||
} from "./io";
|
||||
import { uploadFile } from "./upload";
|
||||
import {
|
||||
@@ -61,5 +62,6 @@ export const FileStorage: IFileStorage = {
|
||||
exists,
|
||||
clearFileStorage,
|
||||
getUploadedFileSize,
|
||||
bulkExists
|
||||
bulkExists,
|
||||
bulkDeleteFiles
|
||||
};
|
||||
|
||||
@@ -30,7 +30,14 @@ import RNFetchBlob from "react-native-blob-util";
|
||||
import { eSendEvent } from "../../services/event-manager";
|
||||
import { IOS_APPGROUPID } from "../../utils/constants";
|
||||
import { DatabaseLogger, db } from "../database";
|
||||
import { ABYTES, cacheDir, cacheDirOld, getRandomId } from "./utils";
|
||||
import {
|
||||
ABYTES,
|
||||
cacheDir,
|
||||
cacheDirOld,
|
||||
getRandomId,
|
||||
isSuccessStatusCode,
|
||||
parseS3Error
|
||||
} from "./utils";
|
||||
|
||||
export async function readEncrypted<TOutputFormat extends DataFormat>(
|
||||
filename: string,
|
||||
@@ -102,16 +109,41 @@ export async function writeEncryptedBase64(
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteLocalFile(filename: string) {
|
||||
try {
|
||||
await createCacheDir();
|
||||
let path = cacheDir + `/${filename}`;
|
||||
let exists = await RNFetchBlob.fs.exists(path);
|
||||
if (Platform.OS === "ios" && !exists) {
|
||||
const iosAppGroup =
|
||||
Platform.OS === "ios"
|
||||
? await (RNFetchBlob.fs as any).pathForAppGroup(IOS_APPGROUPID)
|
||||
: null;
|
||||
const appGroupPath = `${iosAppGroup}/${filename}`;
|
||||
if (await RNFetchBlob.fs.exists(appGroupPath)) {
|
||||
RNFetchBlob.fs.unlink(appGroupPath).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (exists) {
|
||||
RNFetchBlob.fs.unlink(path).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
DatabaseLogger.error(e as Error, "deleteLocalFile");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFile(
|
||||
filename: string,
|
||||
requestOptions?: RequestOptions
|
||||
): Promise<boolean> {
|
||||
await createCacheDir();
|
||||
const localFilePath = cacheDir + `/${filename}`;
|
||||
if (!requestOptions) {
|
||||
RNFetchBlob.fs.unlink(localFilePath).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
deleteLocalFile(filename);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -122,9 +154,7 @@ export async function deleteFile(
|
||||
const status = response.info().status;
|
||||
const ok = status >= 200 && status < 300;
|
||||
if (ok) {
|
||||
RNFetchBlob.fs.unlink(localFilePath).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
deleteLocalFile(filename);
|
||||
}
|
||||
return ok;
|
||||
} catch (e) {
|
||||
@@ -135,6 +165,49 @@ export async function deleteFile(
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkDeleteFiles(
|
||||
filenames: string[],
|
||||
requestOptions?: RequestOptions
|
||||
) {
|
||||
await createCacheDir();
|
||||
if (!requestOptions) {
|
||||
filenames.forEach((filename) => {
|
||||
deleteLocalFile(filename);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, headers } = requestOptions;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
names: filenames
|
||||
})
|
||||
});
|
||||
|
||||
const result = isSuccessStatusCode(response.status);
|
||||
if (result) {
|
||||
filenames.forEach((filename) => {
|
||||
deleteLocalFile(filename);
|
||||
});
|
||||
} else {
|
||||
throw await response.text();
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
DatabaseLogger.error(
|
||||
typeof e === "string" ? parseS3Error(e as string) : (e as Error),
|
||||
"Could not bulk delete files"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearFileStorage() {
|
||||
try {
|
||||
await createCacheDir();
|
||||
|
||||
@@ -193,3 +193,7 @@ export async function checkAndCreateDir(path: string) {
|
||||
export const santizeUri = (uri: string) => {
|
||||
return Platform.OS === "ios" ? decodeURI(uri).replace("file:///", "/") : uri;
|
||||
};
|
||||
|
||||
export function isSuccessStatusCode(statusCode: number) {
|
||||
return statusCode >= 200 && statusCode <= 299;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { ActivityIndicator, FlatList, View } from "react-native";
|
||||
import { ScrollView } from "react-native-actions-sheet";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import create from "zustand";
|
||||
@@ -313,6 +313,10 @@ export const AttachmentDialog = ({
|
||||
});
|
||||
};
|
||||
|
||||
db.attachments.orphaned.items().then((r) => {
|
||||
console.log(r);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSheet ? (
|
||||
@@ -509,7 +513,7 @@ export const AttachmentDialog = ({
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<LegendList
|
||||
<FlatList
|
||||
renderScrollComponent={(props) => <ScrollView {...props} />}
|
||||
keyboardDismissMode="none"
|
||||
keyboardShouldPersistTaps="always"
|
||||
@@ -543,7 +547,6 @@ export const AttachmentDialog = ({
|
||||
}}
|
||||
/>
|
||||
}
|
||||
estimatedItemSize={50}
|
||||
data={loading ? [] : attachments?.placeholders || []}
|
||||
extraData={attachments}
|
||||
renderItem={renderItem}
|
||||
|
||||
@@ -73,7 +73,15 @@ export const ChangePassword = () => {
|
||||
throw new Error(strings.backupFailed() + `: ${result.error}`);
|
||||
}
|
||||
|
||||
await db.user.changePassword(oldPassword.current, password.current);
|
||||
const passwordChanged = await db.user.changePassword(
|
||||
oldPassword.current,
|
||||
password.current
|
||||
);
|
||||
|
||||
if (!passwordChanged) {
|
||||
throw new Error("Could not change user account password.");
|
||||
}
|
||||
|
||||
ToastManager.show({
|
||||
heading: strings.passwordChangedSuccessfully(),
|
||||
type: "success",
|
||||
|
||||
@@ -39,7 +39,7 @@ export function hideAuth(context?: AuthParams["context"]) {
|
||||
initialAuthMode.current === AuthMode.welcomeLogin ||
|
||||
context === "intro"
|
||||
) {
|
||||
Navigation.replace("FluidPanelsView", {});
|
||||
Navigation.navigate("FluidPanelsView", {});
|
||||
} else {
|
||||
Navigation.goBack();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import ActionSheet from "react-native-actions-sheet";
|
||||
import { db } from "../../common/database";
|
||||
import { DDS } from "../../services/device-detection";
|
||||
import { ToastManager } from "../../services/event-manager";
|
||||
@@ -35,9 +34,9 @@ import Paragraph from "../ui/typography/paragraph";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
|
||||
export const ForgotPassword = () => {
|
||||
export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
|
||||
const { colors } = useThemeColors("sheet");
|
||||
const email = useRef<string>(undefined);
|
||||
const email = useRef<string>(userEmail);
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -87,94 +86,76 @@ export const ForgotPassword = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionSheet
|
||||
onBeforeShow={(data) => (email.current = data)}
|
||||
onClose={() => {
|
||||
setSent(false);
|
||||
setLoading(false);
|
||||
}}
|
||||
onOpen={() => {
|
||||
emailInputRef.current?.setNativeProps({
|
||||
text: email.current
|
||||
});
|
||||
}}
|
||||
indicatorStyle={{
|
||||
width: 100
|
||||
}}
|
||||
gestureEnabled
|
||||
id="forgotpassword_sheet"
|
||||
>
|
||||
{sent ? (
|
||||
<View
|
||||
{sent ? (
|
||||
<View
|
||||
style={{
|
||||
padding: DefaultAppStyles.GAP,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingBottom: 50
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
style={{
|
||||
padding: DefaultAppStyles.GAP,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingBottom: 50
|
||||
width: null,
|
||||
height: null
|
||||
}}
|
||||
color={colors.primary.accent}
|
||||
name="email"
|
||||
size={50}
|
||||
/>
|
||||
<Heading>{strings.recoveryEmailSent()}</Heading>
|
||||
<Paragraph
|
||||
style={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
style={{
|
||||
width: null,
|
||||
height: null
|
||||
}}
|
||||
color={colors.primary.accent}
|
||||
name="email"
|
||||
size={50}
|
||||
/>
|
||||
<Heading>{strings.recoveryEmailSent()}</Heading>
|
||||
<Paragraph
|
||||
style={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
{strings.recoveryEmailSentDesc()}
|
||||
</Paragraph>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
borderRadius: DDS.isTab ? 5 : 0,
|
||||
backgroundColor: colors.primary.background,
|
||||
zIndex: 10,
|
||||
width: "100%",
|
||||
padding: DefaultAppStyles.GAP
|
||||
{strings.recoveryEmailSentDesc()}
|
||||
</Paragraph>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
borderRadius: DDS.isTab ? 5 : 0,
|
||||
backgroundColor: colors.primary.background,
|
||||
zIndex: 10,
|
||||
width: "100%",
|
||||
padding: DefaultAppStyles.GAP
|
||||
}}
|
||||
>
|
||||
<DialogHeader title={strings.accountRecovery()} />
|
||||
<Seperator />
|
||||
|
||||
<Input
|
||||
fwdRef={emailInputRef}
|
||||
onChangeText={(value) => {
|
||||
email.current = value;
|
||||
}}
|
||||
>
|
||||
<DialogHeader title={strings.accountRecovery()} />
|
||||
<Seperator />
|
||||
defaultValue={email.current}
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
returnKeyLabel={strings.next()}
|
||||
returnKeyType="next"
|
||||
autoComplete="email"
|
||||
validationType="email"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
errorMessage={strings.emailInvalid()}
|
||||
placeholder={strings.email()}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
fwdRef={emailInputRef}
|
||||
onChangeText={(value) => {
|
||||
email.current = value;
|
||||
}}
|
||||
defaultValue={email.current}
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
returnKeyLabel={strings.next()}
|
||||
returnKeyType="next"
|
||||
autoComplete="email"
|
||||
validationType="email"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
errorMessage={strings.emailInvalid()}
|
||||
placeholder={strings.email()}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
width: "100%"
|
||||
}}
|
||||
loading={loading}
|
||||
onPress={sendRecoveryEmail}
|
||||
type="accent"
|
||||
title={loading ? null : strings.next()}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ActionSheet>
|
||||
<Button
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
width: "100%"
|
||||
}}
|
||||
loading={loading}
|
||||
onPress={sendRecoveryEmail}
|
||||
type="accent"
|
||||
title={loading ? null : strings.next()}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,11 @@ import { TouchableOpacity, View, useWindowDimensions } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
||||
import { DDS } from "../../services/device-detection";
|
||||
import { eSendEvent, ToastManager } from "../../services/event-manager";
|
||||
import {
|
||||
eSendEvent,
|
||||
presentSheet,
|
||||
ToastManager
|
||||
} from "../../services/event-manager";
|
||||
import Navigation from "../../services/navigation";
|
||||
import PremiumService from "../../services/premium";
|
||||
import SettingsService from "../../services/settings";
|
||||
@@ -110,7 +114,6 @@ export const Login = ({
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<ForgotPassword />
|
||||
<Dialog context="two_factor_verify" />
|
||||
<KeyboardAwareScrollView
|
||||
style={{
|
||||
@@ -257,13 +260,10 @@ export const Login = ({
|
||||
paddingHorizontal: 0
|
||||
}}
|
||||
onPress={() => {
|
||||
ToastManager.show({
|
||||
type: "info",
|
||||
message:
|
||||
"Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved."
|
||||
if (loading || !email.current) return;
|
||||
presentSheet({
|
||||
component: <ForgotPassword userEmail={email.current} />
|
||||
});
|
||||
// if (loading || !email.current) return;
|
||||
// SheetManager.show("forgotpassword_sheet");
|
||||
}}
|
||||
textStyle={{
|
||||
textDecorationLine: "underline"
|
||||
|
||||
@@ -97,7 +97,7 @@ export const SessionExpired = () => {
|
||||
if (db.tokenManager._isTokenExpired(res))
|
||||
throw new Error("token expired");
|
||||
|
||||
const key = await db.user.getEncryptionKey();
|
||||
const key = await db.user.getDataEncryptionKeys();
|
||||
if (!key) throw new Error("No encryption key found.");
|
||||
|
||||
Sync.run("global", false, "full", async (complete) => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DDS } from "../../../services/device-detection";
|
||||
import {
|
||||
ToastManager,
|
||||
Vault,
|
||||
VaultRequestType,
|
||||
eSendEvent,
|
||||
eSubscribeEvent,
|
||||
eUnSubscribeEvent
|
||||
@@ -58,148 +59,104 @@ import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import { Note, NoteContent, VAULT_ERRORS } from "@notesnook/core";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
|
||||
type VaultDialogData = {
|
||||
item: Note;
|
||||
} & Partial<Omit<Vault, "item">>;
|
||||
|
||||
interface VaultDialogState {
|
||||
visible: boolean;
|
||||
wrongPassword: boolean;
|
||||
loading: boolean;
|
||||
note?: Note;
|
||||
vault: boolean;
|
||||
locked: boolean;
|
||||
permanant: boolean;
|
||||
goToEditor: boolean;
|
||||
share: boolean;
|
||||
passwordsDontMatch: boolean;
|
||||
deleteNote: boolean;
|
||||
focusIndex: number | null;
|
||||
biometricUnlock: boolean;
|
||||
isBiometryEnrolled: boolean;
|
||||
isBiometryAvailable: boolean;
|
||||
fingerprintAccess: boolean;
|
||||
changePassword: boolean;
|
||||
copyNote: boolean;
|
||||
revokeFingerprintAccess: boolean;
|
||||
title: string;
|
||||
description: string | null;
|
||||
clearVault: boolean;
|
||||
deleteVault: boolean;
|
||||
deleteAll: boolean;
|
||||
noteLocked: boolean;
|
||||
novault: boolean;
|
||||
customActionTitle: string | null;
|
||||
customActionParagraph: string | null;
|
||||
customAction: boolean;
|
||||
onUnlock?: (
|
||||
item: Note & {
|
||||
content?: NoteContent<false>;
|
||||
},
|
||||
password: string
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const VaultDialog: React.FC = () => {
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
const [state, setState] = useState<VaultDialogState>({
|
||||
visible: false,
|
||||
wrongPassword: false,
|
||||
loading: false,
|
||||
note: undefined,
|
||||
vault: false,
|
||||
locked: true,
|
||||
permanant: false,
|
||||
goToEditor: false,
|
||||
share: false,
|
||||
passwordsDontMatch: false,
|
||||
deleteNote: false,
|
||||
focusIndex: null,
|
||||
biometricUnlock: false,
|
||||
isBiometryEnrolled: false,
|
||||
isBiometryAvailable: false,
|
||||
fingerprintAccess: false,
|
||||
changePassword: false,
|
||||
copyNote: false,
|
||||
revokeFingerprintAccess: false,
|
||||
title: strings.goToEditor(),
|
||||
description: null,
|
||||
clearVault: false,
|
||||
deleteVault: false,
|
||||
deleteAll: false,
|
||||
noteLocked: false,
|
||||
novault: false,
|
||||
customActionTitle: null,
|
||||
customActionParagraph: null,
|
||||
customAction: false,
|
||||
onUnlock: undefined
|
||||
});
|
||||
// UI State
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wrongPassword, setWrongPassword] = useState(false);
|
||||
const [passwordsDontMatch, setPasswordsDontMatch] = useState(false);
|
||||
const [deleteAll, setDeleteAll] = useState(false);
|
||||
const [biometricUnlock, setBiometricUnlock] = useState(false);
|
||||
const [isBiometryAvailable, setIsBiometryAvailable] = useState(false);
|
||||
const [isBiometryEnrolled, setIsBiometryEnrolled] = useState(false);
|
||||
|
||||
// Refs for non-UI state
|
||||
const requestTypeRef = useRef<VaultRequestType | null>(null);
|
||||
const noteRef = useRef<Note | undefined>(undefined);
|
||||
const titleRef = useRef<string>(strings.goToEditor());
|
||||
const descriptionRef = useRef<string | null>(null);
|
||||
const paragraphRef = useRef<string | null>(null);
|
||||
const buttonTitleRef = useRef<string | null>(null);
|
||||
const positiveButtonTypeRef = useRef<"errorShade" | "transparent" | "accent">(
|
||||
"transparent"
|
||||
);
|
||||
const customActionTitleRef = useRef<string | null>(null);
|
||||
const customActionParagraphRef = useRef<string | null>(null);
|
||||
const noteLockedRef = useRef(false);
|
||||
const onUnlockRef = useRef<
|
||||
| ((
|
||||
item: Note & {
|
||||
content?: NoteContent<false>;
|
||||
},
|
||||
password: string
|
||||
) => void)
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
// Input refs
|
||||
const passInputRef = useRef<TextInput>(null);
|
||||
const confirmPassRef = useRef<TextInput>(null);
|
||||
const changePassInputRef = useRef<TextInput>(null);
|
||||
|
||||
// Password refs
|
||||
const passwordRef = useRef<string | null>(null);
|
||||
const confirmPasswordRef = useRef<string | null>(null);
|
||||
const newPasswordRef = useRef<string | null>(null);
|
||||
|
||||
const open = useCallback(async (data: VaultDialogData) => {
|
||||
const open = useCallback(async (data: Vault) => {
|
||||
const biometry = await BiometricService.isBiometryAvailable();
|
||||
const available = !!biometry;
|
||||
const fingerprint = await BiometricService.hasInternetCredentials();
|
||||
|
||||
const noteLocked = data.item
|
||||
? await db.vaults.itemExists(data.item)
|
||||
: false;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
note: data.item,
|
||||
novault: data.novault || false,
|
||||
locked: data.locked || false,
|
||||
permanant: data.permanant || false,
|
||||
goToEditor: data.goToEditor || false,
|
||||
share: data.share || false,
|
||||
deleteNote: data.deleteNote || false,
|
||||
copyNote: data.copyNote || false,
|
||||
isBiometryAvailable: available,
|
||||
biometricUnlock: fingerprint,
|
||||
isBiometryEnrolled: fingerprint,
|
||||
fingerprintAccess: data.fingerprintAccess || false,
|
||||
changePassword: data.changePassword || false,
|
||||
revokeFingerprintAccess: data.revokeFingerprintAccess || false,
|
||||
title: data.title || strings.goToEditor(),
|
||||
description: data.description || null,
|
||||
clearVault: data.clearVault || false,
|
||||
deleteVault: data.deleteVault || false,
|
||||
noteLocked,
|
||||
customActionTitle: data.customActionTitle || null,
|
||||
customActionParagraph: data.customActionParagraph || null,
|
||||
customAction: !!(data.customActionTitle && data.customActionParagraph),
|
||||
onUnlock: data.onUnlock
|
||||
}));
|
||||
// Set refs
|
||||
noteRef.current = data.item;
|
||||
titleRef.current = data.title || strings.goToEditor();
|
||||
descriptionRef.current = data.description || null;
|
||||
paragraphRef.current = data.paragraph || null;
|
||||
buttonTitleRef.current = data.buttonTitle || null;
|
||||
positiveButtonTypeRef.current = data.positiveButtonType || "transparent";
|
||||
customActionTitleRef.current = data.customActionTitle || null;
|
||||
customActionParagraphRef.current = data.customActionParagraph || null;
|
||||
noteLockedRef.current = noteLocked;
|
||||
onUnlockRef.current = data.onUnlock;
|
||||
requestTypeRef.current = data.requestType;
|
||||
|
||||
if (
|
||||
// Set UI state
|
||||
setIsBiometryAvailable(available);
|
||||
setIsBiometryEnrolled(fingerprint);
|
||||
setBiometricUnlock(fingerprint);
|
||||
setWrongPassword(false);
|
||||
setPasswordsDontMatch(false);
|
||||
setDeleteAll(false);
|
||||
setLoading(false);
|
||||
|
||||
// Auto-unlock with fingerprint if applicable
|
||||
const canAutoUnlock =
|
||||
fingerprint &&
|
||||
data.novault &&
|
||||
!data.fingerprintAccess &&
|
||||
!data.revokeFingerprintAccess &&
|
||||
!data.changePassword &&
|
||||
!data.clearVault &&
|
||||
!data.deleteVault &&
|
||||
!data.customActionTitle
|
||||
) {
|
||||
data.requestType !== VaultRequestType.EnableFingerprint &&
|
||||
data.requestType !== VaultRequestType.RevokeFingerprint &&
|
||||
data.requestType !== VaultRequestType.ChangePassword &&
|
||||
data.requestType !== VaultRequestType.ClearVault &&
|
||||
data.requestType !== VaultRequestType.DeleteVault &&
|
||||
data.requestType !== VaultRequestType.CustomAction &&
|
||||
data.requestType !== VaultRequestType.PermanentUnlock;
|
||||
|
||||
if (canAutoUnlock) {
|
||||
await onPressFingerprintAuth(data.title, data.description);
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, visible: true }));
|
||||
setVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (state.loading) {
|
||||
if (loading) {
|
||||
ToastManager.show({
|
||||
heading: state.title,
|
||||
heading: titleRef.current,
|
||||
message: strings.pleaseWait() + "...",
|
||||
type: "success",
|
||||
context: "local"
|
||||
@@ -209,46 +166,37 @@ export const VaultDialog: React.FC = () => {
|
||||
|
||||
Navigation.queueRoutesForUpdate();
|
||||
|
||||
// Reset password refs
|
||||
passwordRef.current = null;
|
||||
confirmPasswordRef.current = null;
|
||||
newPasswordRef.current = null;
|
||||
|
||||
setState({
|
||||
visible: false,
|
||||
wrongPassword: false,
|
||||
loading: false,
|
||||
note: undefined,
|
||||
vault: false,
|
||||
locked: false,
|
||||
permanant: false,
|
||||
goToEditor: false,
|
||||
share: false,
|
||||
passwordsDontMatch: false,
|
||||
deleteNote: false,
|
||||
focusIndex: null,
|
||||
biometricUnlock: false,
|
||||
isBiometryEnrolled: false,
|
||||
isBiometryAvailable: false,
|
||||
fingerprintAccess: false,
|
||||
changePassword: false,
|
||||
copyNote: false,
|
||||
revokeFingerprintAccess: false,
|
||||
title: strings.goToEditor(),
|
||||
description: null,
|
||||
clearVault: false,
|
||||
deleteVault: false,
|
||||
deleteAll: false,
|
||||
noteLocked: false,
|
||||
novault: false,
|
||||
customActionTitle: null,
|
||||
customActionParagraph: null,
|
||||
customAction: false,
|
||||
onUnlock: undefined
|
||||
});
|
||||
}, [state.loading, state.title]);
|
||||
// Reset refs
|
||||
requestTypeRef.current = null;
|
||||
noteRef.current = undefined;
|
||||
titleRef.current = strings.goToEditor();
|
||||
descriptionRef.current = null;
|
||||
paragraphRef.current = null;
|
||||
buttonTitleRef.current = null;
|
||||
positiveButtonTypeRef.current = "transparent";
|
||||
customActionTitleRef.current = null;
|
||||
customActionParagraphRef.current = null;
|
||||
noteLockedRef.current = false;
|
||||
onUnlockRef.current = undefined;
|
||||
|
||||
// Reset UI state
|
||||
setVisible(false);
|
||||
setLoading(false);
|
||||
setWrongPassword(false);
|
||||
setPasswordsDontMatch(false);
|
||||
setDeleteAll(false);
|
||||
setBiometricUnlock(false);
|
||||
setIsBiometryAvailable(false);
|
||||
setIsBiometryEnrolled(false);
|
||||
}, [loading]);
|
||||
|
||||
const deleteVault = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, loading: true }));
|
||||
setLoading(true);
|
||||
try {
|
||||
let verified = true;
|
||||
if (await db.user.getUser()) {
|
||||
@@ -256,7 +204,7 @@ export const VaultDialog: React.FC = () => {
|
||||
}
|
||||
if (verified) {
|
||||
let noteIds: string[] = [];
|
||||
if (state.deleteAll) {
|
||||
if (deleteAll) {
|
||||
const vault = await db.vaults.default();
|
||||
const relations = await db.relations
|
||||
.from(
|
||||
@@ -269,9 +217,9 @@ export const VaultDialog: React.FC = () => {
|
||||
.get();
|
||||
noteIds = relations.map((item) => item.toId);
|
||||
}
|
||||
await db.vault.delete(state.deleteAll);
|
||||
await db.vault.delete(deleteAll);
|
||||
|
||||
if (state.deleteAll) {
|
||||
if (deleteAll) {
|
||||
noteIds.forEach((id) => {
|
||||
eSendEvent(
|
||||
eUpdateNoteInEditor,
|
||||
@@ -284,7 +232,7 @@ export const VaultDialog: React.FC = () => {
|
||||
});
|
||||
}
|
||||
eSendEvent("vaultUpdated");
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
setTimeout(() => {
|
||||
close();
|
||||
}, 100);
|
||||
@@ -298,10 +246,10 @@ export const VaultDialog: React.FC = () => {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [state.deleteAll, close]);
|
||||
}, [deleteAll, close]);
|
||||
|
||||
const clearVault = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, loading: true }));
|
||||
setLoading(true);
|
||||
try {
|
||||
const vault = await db.vaults.default();
|
||||
const relations = await db.relations.from(vault!, "note").get();
|
||||
@@ -319,7 +267,7 @@ export const VaultDialog: React.FC = () => {
|
||||
true
|
||||
);
|
||||
});
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
close();
|
||||
eSendEvent("vaultUpdated");
|
||||
} catch (e) {
|
||||
@@ -329,7 +277,7 @@ export const VaultDialog: React.FC = () => {
|
||||
context: "local"
|
||||
});
|
||||
}
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
}, [close]);
|
||||
|
||||
const lockNote = useCallback(async () => {
|
||||
@@ -341,9 +289,9 @@ export const VaultDialog: React.FC = () => {
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
await db.vault.add(state.note!.id);
|
||||
await db.vault.add(noteRef.current!.id);
|
||||
|
||||
eSendEvent(eUpdateNoteInEditor, state.note, true);
|
||||
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
|
||||
|
||||
close();
|
||||
ToastManager.show({
|
||||
@@ -351,26 +299,29 @@ export const VaultDialog: React.FC = () => {
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
}
|
||||
}, [state.note, close]);
|
||||
}, [close]);
|
||||
|
||||
const permanantUnlock = useCallback(() => {
|
||||
db.vault
|
||||
.remove(state.note!.id, passwordRef.current || "")
|
||||
.then(() => {
|
||||
.remove(noteRef.current!.id, passwordRef.current || "")
|
||||
.then(async () => {
|
||||
ToastManager.show({
|
||||
heading: strings.noteUnlocked(),
|
||||
type: "success",
|
||||
context: "global"
|
||||
});
|
||||
eSendEvent(eUpdateNoteInEditor, state.note, true);
|
||||
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
|
||||
if (biometricUnlock && !isBiometryEnrolled) {
|
||||
await enrollFingerprint(passwordRef.current || "");
|
||||
}
|
||||
close();
|
||||
})
|
||||
.catch((e) => {
|
||||
takeErrorAction();
|
||||
});
|
||||
}, [state.note, close]);
|
||||
}, [close, biometricUnlock, isBiometryEnrolled]);
|
||||
|
||||
const openInEditor = useCallback(
|
||||
(note: Note & { content?: NoteContent<false> }) => {
|
||||
@@ -418,20 +369,17 @@ export const VaultDialog: React.FC = () => {
|
||||
|
||||
const deleteNote = useCallback(async () => {
|
||||
try {
|
||||
await db.vault.remove(state.note!.id, passwordRef.current || "");
|
||||
await deleteItems("note", [state.note!.id]);
|
||||
await db.vault.remove(noteRef.current!.id, passwordRef.current || "");
|
||||
await deleteItems("note", [noteRef.current!.id]);
|
||||
close();
|
||||
} catch (e) {
|
||||
takeErrorAction();
|
||||
}
|
||||
}, [state.note, close]);
|
||||
}, [close]);
|
||||
|
||||
const takeErrorAction = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
wrongPassword: true,
|
||||
visible: true
|
||||
}));
|
||||
setWrongPassword(true);
|
||||
setVisible(true);
|
||||
setTimeout(() => {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
@@ -445,39 +393,40 @@ export const VaultDialog: React.FC = () => {
|
||||
try {
|
||||
if (!passwordRef.current) throw new Error("Invalid password");
|
||||
|
||||
const note = await db.vault.open(state.note!.id, passwordRef.current);
|
||||
const note = await db.vault.open(
|
||||
noteRef.current!.id,
|
||||
passwordRef.current
|
||||
);
|
||||
if (!note) throw new Error("Failed to unlock note.");
|
||||
if (state.biometricUnlock && !state.isBiometryEnrolled) {
|
||||
if (biometricUnlock && !isBiometryEnrolled) {
|
||||
await enrollFingerprint(passwordRef.current || "");
|
||||
}
|
||||
|
||||
if (state.goToEditor) {
|
||||
const requestType = requestTypeRef.current;
|
||||
|
||||
if (requestType === VaultRequestType.GoToEditor) {
|
||||
openInEditor(note);
|
||||
} else if (state.share) {
|
||||
} else if (requestType === VaultRequestType.ShareNote) {
|
||||
await shareNote(note);
|
||||
} else if (state.deleteNote) {
|
||||
} else if (requestType === VaultRequestType.DeleteNote) {
|
||||
await deleteNote();
|
||||
} else if (state.copyNote) {
|
||||
} else if (requestType === VaultRequestType.CopyNote) {
|
||||
await copyNote(note);
|
||||
} else if (state.customAction && state.onUnlock) {
|
||||
} else if (
|
||||
requestType === VaultRequestType.CustomAction &&
|
||||
onUnlockRef.current
|
||||
) {
|
||||
const password = passwordRef.current;
|
||||
close();
|
||||
await sleep(300);
|
||||
state.onUnlock(note, password);
|
||||
onUnlockRef.current(note, password);
|
||||
}
|
||||
} catch (e) {
|
||||
takeErrorAction();
|
||||
}
|
||||
}, [
|
||||
state.note,
|
||||
state.biometricUnlock,
|
||||
state.isBiometryEnrolled,
|
||||
state.goToEditor,
|
||||
state.share,
|
||||
state.deleteNote,
|
||||
state.copyNote,
|
||||
state.customAction,
|
||||
state.onUnlock,
|
||||
biometricUnlock,
|
||||
isBiometryEnrolled,
|
||||
openInEditor,
|
||||
shareNote,
|
||||
deleteNote,
|
||||
@@ -495,20 +444,20 @@ export const VaultDialog: React.FC = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.permanant) {
|
||||
if (requestTypeRef.current === VaultRequestType.PermanentUnlock) {
|
||||
permanantUnlock();
|
||||
} else {
|
||||
await openNote();
|
||||
}
|
||||
}, [state.permanant, permanantUnlock, openNote]);
|
||||
}, [permanantUnlock, openNote]);
|
||||
|
||||
const enrollFingerprint = useCallback(
|
||||
async (password: string) => {
|
||||
setState((prev) => ({ ...prev, loading: true }));
|
||||
setLoading(true);
|
||||
try {
|
||||
await db.vault.unlock(password);
|
||||
await BiometricService.storeCredentials(password);
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
eSendEvent("vaultUpdated");
|
||||
ToastManager.show({
|
||||
heading: strings.biometricUnlockEnabled(),
|
||||
@@ -523,7 +472,7 @@ export const VaultDialog: React.FC = () => {
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[close]
|
||||
@@ -532,13 +481,13 @@ export const VaultDialog: React.FC = () => {
|
||||
const createVault = useCallback(async () => {
|
||||
await db.vault.create(passwordRef.current || "");
|
||||
|
||||
if (state.biometricUnlock) {
|
||||
if (biometricUnlock) {
|
||||
await enrollFingerprint(passwordRef.current || "");
|
||||
}
|
||||
if (state.note?.id) {
|
||||
await db.vault.add(state.note.id);
|
||||
eSendEvent(eUpdateNoteInEditor, state.note, true);
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
if (noteRef.current?.id) {
|
||||
await db.vault.add(noteRef.current.id);
|
||||
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: strings.noteLocked(),
|
||||
type: "success",
|
||||
@@ -554,7 +503,7 @@ export const VaultDialog: React.FC = () => {
|
||||
close();
|
||||
}
|
||||
eSendEvent("vaultUpdated");
|
||||
}, [state.biometricUnlock, state.note, enrollFingerprint, close]);
|
||||
}, [biometricUnlock, enrollFingerprint, close]);
|
||||
|
||||
const revokeFingerprintAccess = useCallback(async () => {
|
||||
try {
|
||||
@@ -578,8 +527,8 @@ export const VaultDialog: React.FC = () => {
|
||||
async (title?: string, description?: string) => {
|
||||
try {
|
||||
const credentials = await BiometricService.getCredentials(
|
||||
title || state.title,
|
||||
description || state.description || ""
|
||||
title || titleRef.current,
|
||||
description || descriptionRef.current || ""
|
||||
);
|
||||
|
||||
if (!credentials) throw new Error("Failed to get user credentials");
|
||||
@@ -590,22 +539,25 @@ export const VaultDialog: React.FC = () => {
|
||||
} else {
|
||||
eSendEvent(eCloseActionSheet);
|
||||
await sleep(300);
|
||||
setState((prev) => ({ ...prev, visible: true }));
|
||||
setVisible(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[state.title, state.description]
|
||||
[]
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
if (state.revokeFingerprintAccess) {
|
||||
const requestType = requestTypeRef.current;
|
||||
|
||||
if (requestType === VaultRequestType.RevokeFingerprint) {
|
||||
await revokeFingerprintAccess();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (state.loading) return;
|
||||
|
||||
if (loading) return;
|
||||
|
||||
if (!passwordRef.current) {
|
||||
ToastManager.show({
|
||||
@@ -616,26 +568,26 @@ export const VaultDialog: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.novault) {
|
||||
if (requestType === VaultRequestType.CreateVault) {
|
||||
if (passwordRef.current !== confirmPasswordRef.current) {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordNotMatched(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setState((prev) => ({ ...prev, passwordsDontMatch: true }));
|
||||
setPasswordsDontMatch(true);
|
||||
return;
|
||||
}
|
||||
|
||||
createVault();
|
||||
} else if (state.changePassword) {
|
||||
setState((prev) => ({ ...prev, loading: true }));
|
||||
} else if (requestType === VaultRequestType.ChangePassword) {
|
||||
setLoading(true);
|
||||
|
||||
db.vault
|
||||
.changePassword(passwordRef.current, newPasswordRef.current || "")
|
||||
.then(() => {
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
if (state.biometricUnlock) {
|
||||
setLoading(false);
|
||||
if (biometricUnlock) {
|
||||
enrollFingerprint(newPasswordRef.current || "");
|
||||
}
|
||||
ToastManager.show({
|
||||
@@ -646,7 +598,7 @@ export const VaultDialog: React.FC = () => {
|
||||
close();
|
||||
})
|
||||
.catch((e) => {
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
setLoading(false);
|
||||
if (e.message === VAULT_ERRORS.wrongPassword) {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
@@ -657,50 +609,62 @@ export const VaultDialog: React.FC = () => {
|
||||
ToastManager.error(e);
|
||||
}
|
||||
});
|
||||
} else if (state.locked) {
|
||||
} else if (requestType === VaultRequestType.LockNote) {
|
||||
if (!passwordRef.current || passwordRef.current.trim() === "") {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setState((prev) => ({ ...prev, wrongPassword: true }));
|
||||
setWrongPassword(true);
|
||||
return;
|
||||
}
|
||||
if (state.noteLocked) {
|
||||
db.vault
|
||||
.unlock(passwordRef.current)
|
||||
.then(async (unlocked) => {
|
||||
if (unlocked) {
|
||||
setWrongPassword(false);
|
||||
await lockNote();
|
||||
} else {
|
||||
takeErrorAction();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
takeErrorAction();
|
||||
});
|
||||
} else if (
|
||||
requestType === VaultRequestType.UnlockNote ||
|
||||
requestType === VaultRequestType.PermanentUnlock ||
|
||||
requestType === VaultRequestType.GoToEditor ||
|
||||
requestType === VaultRequestType.ShareNote ||
|
||||
requestType === VaultRequestType.CopyNote ||
|
||||
requestType === VaultRequestType.DeleteNote ||
|
||||
requestType === VaultRequestType.CustomAction
|
||||
) {
|
||||
if (!passwordRef.current || passwordRef.current.trim() === "") {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setWrongPassword(true);
|
||||
return;
|
||||
}
|
||||
if (noteLockedRef.current) {
|
||||
await unlockNote();
|
||||
} else {
|
||||
db.vault
|
||||
.unlock(passwordRef.current)
|
||||
.then(async () => {
|
||||
setState((prev) => ({ ...prev, wrongPassword: false }));
|
||||
await lockNote();
|
||||
})
|
||||
.catch((e) => {
|
||||
takeErrorAction();
|
||||
});
|
||||
console.log("Error: Note should be locked for this operation");
|
||||
}
|
||||
} else if (state.fingerprintAccess) {
|
||||
} else if (requestType === VaultRequestType.EnableFingerprint) {
|
||||
enrollFingerprint(passwordRef.current);
|
||||
} else if (state.clearVault) {
|
||||
} else if (requestType === VaultRequestType.ClearVault) {
|
||||
await clearVault();
|
||||
} else if (state.deleteVault) {
|
||||
} else if (requestType === VaultRequestType.DeleteVault) {
|
||||
await deleteVault();
|
||||
} else if (state.customAction) {
|
||||
await unlockNote();
|
||||
}
|
||||
}, [
|
||||
state.revokeFingerprintAccess,
|
||||
state.loading,
|
||||
state.novault,
|
||||
state.changePassword,
|
||||
state.locked,
|
||||
state.noteLocked,
|
||||
state.fingerprintAccess,
|
||||
state.clearVault,
|
||||
state.deleteVault,
|
||||
state.customAction,
|
||||
state.biometricUnlock,
|
||||
loading,
|
||||
biometricUnlock,
|
||||
revokeFingerprintAccess,
|
||||
close,
|
||||
createVault,
|
||||
@@ -722,23 +686,21 @@ export const VaultDialog: React.FC = () => {
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
if (!state.visible) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
const {
|
||||
note,
|
||||
novault,
|
||||
deleteNote: shouldDeleteNote,
|
||||
share,
|
||||
goToEditor,
|
||||
fingerprintAccess,
|
||||
changePassword,
|
||||
loading,
|
||||
deleteVault: shouldDeleteVault,
|
||||
clearVault: shouldClearVault,
|
||||
customAction,
|
||||
customActionTitle,
|
||||
customActionParagraph
|
||||
} = state;
|
||||
const requestType = requestTypeRef.current;
|
||||
const isCreateVault = requestType === VaultRequestType.CreateVault;
|
||||
const isChangePassword = requestType === VaultRequestType.ChangePassword;
|
||||
const isClearVault = requestType === VaultRequestType.ClearVault;
|
||||
const isDeleteVault = requestType === VaultRequestType.DeleteVault;
|
||||
const isRevokeFingerprint =
|
||||
requestType === VaultRequestType.RevokeFingerprint;
|
||||
const isEnableFingerprint =
|
||||
requestType === VaultRequestType.EnableFingerprint;
|
||||
const isCustomAction = requestType === VaultRequestType.CustomAction;
|
||||
const isDeleteNote = requestType === VaultRequestType.DeleteNote;
|
||||
const isShareNote = requestType === VaultRequestType.ShareNote;
|
||||
const isGoToEditor = requestType === VaultRequestType.GoToEditor;
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
@@ -760,8 +722,10 @@ export const VaultDialog: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<DialogHeader
|
||||
title={state.title}
|
||||
paragraph={customActionParagraph || ""}
|
||||
title={titleRef.current}
|
||||
paragraph={
|
||||
paragraphRef.current || customActionParagraphRef.current || ""
|
||||
}
|
||||
icon="shield"
|
||||
padding={12}
|
||||
/>
|
||||
@@ -772,12 +736,12 @@ export const VaultDialog: React.FC = () => {
|
||||
paddingHorizontal: DefaultAppStyles.GAP
|
||||
}}
|
||||
>
|
||||
{(novault ||
|
||||
changePassword ||
|
||||
shouldClearVault ||
|
||||
shouldDeleteVault ||
|
||||
customAction) &&
|
||||
!state.revokeFingerprintAccess ? (
|
||||
{(isChangePassword ||
|
||||
isClearVault ||
|
||||
!isCreateVault ||
|
||||
isDeleteVault ||
|
||||
isCustomAction) &&
|
||||
!isRevokeFingerprint ? (
|
||||
<>
|
||||
<Input
|
||||
fwdRef={passInputRef}
|
||||
@@ -788,37 +752,40 @@ export const VaultDialog: React.FC = () => {
|
||||
passwordRef.current = value;
|
||||
}}
|
||||
marginBottom={
|
||||
!state.biometricUnlock ||
|
||||
!state.isBiometryEnrolled ||
|
||||
!novault ||
|
||||
changePassword ||
|
||||
customAction
|
||||
!biometricUnlock ||
|
||||
!isBiometryEnrolled ||
|
||||
isCreateVault ||
|
||||
isChangePassword ||
|
||||
isCustomAction
|
||||
? 0
|
||||
: 10
|
||||
}
|
||||
onSubmit={() => {
|
||||
if (changePassword) {
|
||||
if (isChangePassword) {
|
||||
confirmPassRef.current?.focus();
|
||||
} else {
|
||||
onPress();
|
||||
}
|
||||
}}
|
||||
autoComplete="password"
|
||||
returnKeyLabel={changePassword ? strings.next() : state.title}
|
||||
returnKeyType={changePassword ? "next" : "done"}
|
||||
returnKeyLabel={
|
||||
isChangePassword ? strings.next() : titleRef.current
|
||||
}
|
||||
returnKeyType={isChangePassword ? "next" : "done"}
|
||||
secureTextEntry
|
||||
placeholder={
|
||||
changePassword
|
||||
isChangePassword
|
||||
? strings.currentPassword()
|
||||
: strings.password()
|
||||
}
|
||||
/>
|
||||
|
||||
{!state.biometricUnlock ||
|
||||
!state.isBiometryEnrolled ||
|
||||
!novault ||
|
||||
changePassword ||
|
||||
customAction ? null : (
|
||||
{!biometricUnlock ||
|
||||
!isBiometryEnrolled ||
|
||||
!isBiometryAvailable ||
|
||||
isCreateVault ||
|
||||
isChangePassword ||
|
||||
isCustomAction ? null : (
|
||||
<Button
|
||||
onPress={() =>
|
||||
onPressFingerprintAuth(strings.unlockNote(), "")
|
||||
@@ -832,16 +799,11 @@ export const VaultDialog: React.FC = () => {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{shouldDeleteVault && (
|
||||
{isDeleteVault && (
|
||||
<Button
|
||||
onPress={() =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
deleteAll: !prev.deleteAll
|
||||
}))
|
||||
}
|
||||
onPress={() => setDeleteAll(!deleteAll)}
|
||||
icon={
|
||||
state.deleteAll
|
||||
deleteAll
|
||||
? "check-circle-outline"
|
||||
: "checkbox-blank-circle-outline"
|
||||
}
|
||||
@@ -854,7 +816,7 @@ export const VaultDialog: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{changePassword ? (
|
||||
{isChangePassword ? (
|
||||
<>
|
||||
<Seperator half />
|
||||
<Input
|
||||
@@ -877,7 +839,7 @@ export const VaultDialog: React.FC = () => {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!novault ? (
|
||||
{isCreateVault ? (
|
||||
<View>
|
||||
<Input
|
||||
fwdRef={passInputRef}
|
||||
@@ -912,12 +874,9 @@ export const VaultDialog: React.FC = () => {
|
||||
onChangeText={(value) => {
|
||||
confirmPasswordRef.current = value;
|
||||
if (value !== passwordRef.current) {
|
||||
setState((prev) => ({ ...prev, passwordsDontMatch: true }));
|
||||
setPasswordsDontMatch(true);
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
passwordsDontMatch: false
|
||||
}));
|
||||
setPasswordsDontMatch(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={() => {
|
||||
@@ -928,22 +887,23 @@ export const VaultDialog: React.FC = () => {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{state.biometricUnlock && !state.isBiometryEnrolled && novault ? (
|
||||
{biometricUnlock && !isBiometryEnrolled && !isCreateVault ? (
|
||||
<Paragraph>{strings.vaultEnableBiometrics()}</Paragraph>
|
||||
) : null}
|
||||
|
||||
{state.isBiometryAvailable &&
|
||||
!state.fingerprintAccess &&
|
||||
!shouldClearVault &&
|
||||
!shouldDeleteVault &&
|
||||
!customAction &&
|
||||
((!state.biometricUnlock && !changePassword) || !novault) ? (
|
||||
{!biometricUnlock &&
|
||||
!isBiometryEnrolled &&
|
||||
isBiometryAvailable &&
|
||||
(requestType === VaultRequestType.CopyNote ||
|
||||
requestType === VaultRequestType.DeleteNote ||
|
||||
requestType === VaultRequestType.ShareNote ||
|
||||
requestType === VaultRequestType.CustomAction ||
|
||||
requestType === VaultRequestType.GoToEditor ||
|
||||
requestType === VaultRequestType.PermanentUnlock ||
|
||||
requestType === VaultRequestType.LockNote) ? (
|
||||
<Button
|
||||
onPress={() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
biometricUnlock: !prev.biometricUnlock
|
||||
}));
|
||||
setBiometricUnlock(!biometricUnlock);
|
||||
}}
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
@@ -952,11 +912,9 @@ export const VaultDialog: React.FC = () => {
|
||||
width="100%"
|
||||
title={strings.unlockWithBiometrics()}
|
||||
iconColor={
|
||||
state.biometricUnlock
|
||||
? colors.selected.accent
|
||||
: colors.primary.icon
|
||||
biometricUnlock ? colors.selected.accent : colors.primary.icon
|
||||
}
|
||||
type={state.biometricUnlock ? "transparent" : "plain"}
|
||||
type={biometricUnlock ? "transparent" : "plain"}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -965,34 +923,8 @@ export const VaultDialog: React.FC = () => {
|
||||
onPressNegative={close}
|
||||
onPressPositive={onPress}
|
||||
loading={loading}
|
||||
positiveType={
|
||||
shouldDeleteVault || shouldClearVault ? "errorShade" : "transparent"
|
||||
}
|
||||
positiveTitle={
|
||||
shouldDeleteVault
|
||||
? strings.delete()
|
||||
: shouldClearVault
|
||||
? strings.clear()
|
||||
: fingerprintAccess
|
||||
? strings.enable()
|
||||
: state.revokeFingerprintAccess
|
||||
? strings.revoke()
|
||||
: changePassword
|
||||
? strings.change()
|
||||
: customAction && customActionTitle
|
||||
? customActionTitle
|
||||
: state.noteLocked
|
||||
? shouldDeleteNote
|
||||
? strings.delete()
|
||||
: share
|
||||
? strings.share()
|
||||
: goToEditor
|
||||
? strings.open()
|
||||
: strings.unlock()
|
||||
: !note?.id
|
||||
? strings.create()
|
||||
: strings.lock()
|
||||
}
|
||||
positiveType={positiveButtonTypeRef.current}
|
||||
positiveTitle={buttonTitleRef.current || strings.unlock()}
|
||||
/>
|
||||
</View>
|
||||
<Toast context="local" />
|
||||
|
||||
@@ -252,14 +252,16 @@ const NoteItem = ({
|
||||
{reminder ? (
|
||||
<ReminderTime
|
||||
reminder={reminder}
|
||||
disabled
|
||||
color={color?.colorCode}
|
||||
textStyle={{
|
||||
fontSize: AppFontSize.xxxs
|
||||
fontSize: AppFontSize.xxs
|
||||
}}
|
||||
iconSize={AppFontSize.xxxs}
|
||||
short
|
||||
iconSize={AppFontSize.xxs}
|
||||
style={{
|
||||
height: "auto"
|
||||
justifyContent: "flex-start",
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL / 2,
|
||||
alignSelf: "flex-start"
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
eSendEvent,
|
||||
eSubscribeEvent,
|
||||
eUnSubscribeEvent,
|
||||
openVault
|
||||
openVault,
|
||||
VaultRequestType
|
||||
} from "../../services/event-manager";
|
||||
import Navigation from "../../services/navigation";
|
||||
import Sync from "../../services/sync";
|
||||
@@ -128,10 +129,12 @@ const MergeConflicts = () => {
|
||||
let noteContent: UnencryptedContentItem;
|
||||
if (isLocked) {
|
||||
openVault({
|
||||
requestType: VaultRequestType.CustomAction,
|
||||
item: item,
|
||||
novault: true,
|
||||
customActionTitle: "Unlock note",
|
||||
customActionParagraph: "Unlock note to merge conflicts",
|
||||
title: strings.unlockNote(),
|
||||
customActionTitle: strings.unlockNote(),
|
||||
customActionParagraph: strings.unlockNoteToMergeConflicts(),
|
||||
buttonTitle: strings.unlock(),
|
||||
onUnlock: async (item, password) => {
|
||||
if (!item || !password) return;
|
||||
const currentContent = await db.content.get(item.contentId!);
|
||||
|
||||
@@ -76,6 +76,7 @@ import { IconButton } from "../ui/icon-button";
|
||||
import { SvgView } from "../ui/svg";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { db } from "../../common/database";
|
||||
|
||||
const Steps = {
|
||||
select: 1,
|
||||
@@ -139,12 +140,12 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
|
||||
}, [isFocused, step]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = EV.subscribe(
|
||||
const sub = db.eventManager.subscribe(
|
||||
EVENTS.userSubscriptionUpdated,
|
||||
(sub: User["subscription"]) => {
|
||||
if (sub.plan === SubscriptionPlan.FREE) return;
|
||||
if (routeParams.context === "signup") {
|
||||
Navigation.replace("FluidPanelsView", {});
|
||||
Navigation.navigate("FluidPanelsView", {});
|
||||
} else {
|
||||
Navigation.goBack();
|
||||
}
|
||||
@@ -182,8 +183,9 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
|
||||
>
|
||||
<IconButton
|
||||
name="close"
|
||||
color={colors.primary.icon}
|
||||
onPress={() => {
|
||||
Navigation.replace("FluidPanelsView", {});
|
||||
Navigation.navigate("FluidPanelsView", {});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -196,7 +198,7 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
|
||||
return;
|
||||
}
|
||||
if (routeParams.context === "signup") {
|
||||
Navigation.replace("FluidPanelsView", {});
|
||||
Navigation.navigate("FluidPanelsView", {});
|
||||
} else {
|
||||
Navigation.goBack();
|
||||
}
|
||||
@@ -654,7 +656,7 @@ After trying all the privacy security oriented note taking apps, for the price a
|
||||
type="accent"
|
||||
onPress={() => {
|
||||
if (routeParams.context === "signup") {
|
||||
Navigation.replace("FluidPanelsView", {});
|
||||
Navigation.navigate("FluidPanelsView", {});
|
||||
} else {
|
||||
Navigation.goBack();
|
||||
}
|
||||
@@ -1006,8 +1008,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);
|
||||
}}
|
||||
|
||||
@@ -169,7 +169,7 @@ class RecoveryKeySheet extends React.Component {
|
||||
};
|
||||
|
||||
onOpen = async () => {
|
||||
let k = await db.user.getEncryptionKey();
|
||||
let k = await db.user.getMasterKey();
|
||||
this.user = await db.user.getUser();
|
||||
if (k) {
|
||||
this.setState({
|
||||
|
||||
@@ -41,7 +41,9 @@ export const ReminderTime = ({
|
||||
} & ButtonProps) => {
|
||||
const { colors } = useThemeColors();
|
||||
const reminder = props.reminder;
|
||||
const time = !reminder ? undefined : getFormattedReminderTime(reminder);
|
||||
const time = !reminder
|
||||
? undefined
|
||||
: getFormattedReminderTime(reminder, props.short || false);
|
||||
const isTodayOrTomorrow =
|
||||
(time?.includes("Today") || time?.includes("Tomorrow")) &&
|
||||
!time?.includes("Last");
|
||||
|
||||
@@ -54,7 +54,8 @@ import {
|
||||
eSubscribeEvent,
|
||||
openVault,
|
||||
presentSheet,
|
||||
ToastManager
|
||||
ToastManager,
|
||||
VaultRequestType
|
||||
} from "../services/event-manager";
|
||||
import Navigation from "../services/navigation";
|
||||
import Notifications from "../services/notifications";
|
||||
@@ -408,12 +409,12 @@ export const useActions = ({
|
||||
|
||||
if (item.type === "note" && (await db.vaults.itemExists(item))) {
|
||||
openVault({
|
||||
deleteNote: true,
|
||||
novault: true,
|
||||
locked: true,
|
||||
requestType: VaultRequestType.DeleteNote,
|
||||
item: item,
|
||||
title: strings.deleteNote(),
|
||||
description: strings.unlockToDelete()
|
||||
description: strings.unlockToDelete(),
|
||||
buttonTitle: strings.delete(),
|
||||
positiveButtonType: "errorShade"
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
@@ -867,11 +868,10 @@ export const useActions = ({
|
||||
close();
|
||||
await sleep(300);
|
||||
openVault({
|
||||
requestType: VaultRequestType.ShareNote,
|
||||
item: item,
|
||||
novault: true,
|
||||
locked: true,
|
||||
share: true,
|
||||
title: strings.shareNote()
|
||||
title: strings.shareNote(),
|
||||
buttonTitle: strings.share()
|
||||
});
|
||||
} else {
|
||||
processingId.current = "shareNote";
|
||||
@@ -896,11 +896,10 @@ export const useActions = ({
|
||||
close();
|
||||
await sleep(300);
|
||||
openVault({
|
||||
requestType: VaultRequestType.PermanentUnlock,
|
||||
item: item,
|
||||
novault: true,
|
||||
locked: true,
|
||||
permanant: true,
|
||||
title: strings.unlockNote()
|
||||
title: strings.unlockNote(),
|
||||
buttonTitle: strings.unlock()
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -918,17 +917,18 @@ export const useActions = ({
|
||||
switch ((e as Error).message) {
|
||||
case VAULT_ERRORS.noVault:
|
||||
openVault({
|
||||
requestType: VaultRequestType.CreateVault,
|
||||
item: item,
|
||||
novault: false,
|
||||
title: strings.createVault()
|
||||
title: strings.createVault(),
|
||||
buttonTitle: strings.lock()
|
||||
});
|
||||
break;
|
||||
case VAULT_ERRORS.vaultLocked:
|
||||
openVault({
|
||||
requestType: VaultRequestType.LockNote,
|
||||
item: item,
|
||||
novault: true,
|
||||
locked: true,
|
||||
title: strings.lockNote()
|
||||
title: strings.lockNote(),
|
||||
buttonTitle: strings.lock()
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -949,11 +949,10 @@ export const useActions = ({
|
||||
close();
|
||||
await sleep(300);
|
||||
openVault({
|
||||
copyNote: true,
|
||||
novault: true,
|
||||
locked: true,
|
||||
requestType: VaultRequestType.CopyNote,
|
||||
item: item as Note,
|
||||
title: strings.copyNote()
|
||||
title: strings.copyNote(),
|
||||
buttonTitle: strings.copy()
|
||||
});
|
||||
} else {
|
||||
processingId.current = "copyContent";
|
||||
|
||||
@@ -94,6 +94,7 @@ import { SyncStatus, useUserStore } from "../stores/use-user-store";
|
||||
import { updateStatusBarColor } from "../utils/colors";
|
||||
import { BETA } from "../utils/constants";
|
||||
import {
|
||||
eAfterSync,
|
||||
eCloseSheet,
|
||||
eEditorReset,
|
||||
eLoginSessionExpired,
|
||||
@@ -332,6 +333,7 @@ const onLogout = async (reason: string) => {
|
||||
SettingsService.resetSettings();
|
||||
useUserStore.getState().setUser(null);
|
||||
useUserStore.getState().setSyncing(false);
|
||||
eSendEvent(eAfterSync);
|
||||
};
|
||||
|
||||
async function checkForShareExtensionLaunchedInBackground() {
|
||||
@@ -535,6 +537,7 @@ const initializeDatabase = async (password?: string) => {
|
||||
Notifications.restorePinnedNotes();
|
||||
expiringNotesTimer();
|
||||
deleteDCacheFiles();
|
||||
db.attachments.removeOrphaned();
|
||||
}
|
||||
Walkthrough.init();
|
||||
};
|
||||
@@ -561,6 +564,7 @@ export const useAppEvents = () => {
|
||||
initialUrl: string;
|
||||
backupDidWait: boolean;
|
||||
isConnectingSSE: boolean;
|
||||
attachmentsCachedOfflineMode: boolean;
|
||||
}>
|
||||
>({});
|
||||
|
||||
@@ -655,7 +659,11 @@ export const useAppEvents = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (SettingsService.getProperty("offlineMode")) {
|
||||
if (
|
||||
SettingsService.getProperty("offlineMode") &&
|
||||
!refValues.current.attachmentsCachedOfflineMode
|
||||
) {
|
||||
refValues.current.attachmentsCachedOfflineMode = true;
|
||||
db.attachments.cacheAttachments().catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
@@ -722,24 +730,39 @@ export const useAppEvents = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = [
|
||||
EV.subscribe(EVENTS.syncCheckStatus, onCheckSyncStatus),
|
||||
EV.subscribe(EVENTS.syncAborted, onSyncAborted),
|
||||
EV.subscribe(EVENTS.appRefreshRequested, onSyncComplete),
|
||||
db.eventManager.subscribe(EVENTS.syncCheckStatus, onCheckSyncStatus),
|
||||
db.eventManager.subscribe(EVENTS.syncAborted, onSyncAborted),
|
||||
db.eventManager.subscribe(EVENTS.appRefreshRequested, onSyncComplete),
|
||||
db.eventManager.subscribe(EVENTS.userLoggedOut, onLogout),
|
||||
db.eventManager.subscribe(EVENTS.userEmailConfirmed, onUserEmailVerified),
|
||||
EV.subscribe(EVENTS.userSessionExpired, onUserSessionExpired),
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.userSessionExpired,
|
||||
onUserSessionExpired
|
||||
),
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.userSubscriptionUpdated,
|
||||
onUserSubscriptionStatusChanged
|
||||
),
|
||||
EV.subscribe(EVENTS.fileDownload, onDownloadingAttachmentProgress),
|
||||
EV.subscribe(EVENTS.fileUpload, onUploadingAttachmentProgress),
|
||||
EV.subscribe(EVENTS.fileDownloaded, onDownloadedAttachmentProgress),
|
||||
EV.subscribe(EVENTS.fileUploaded, onUploadedAttachmentProgress),
|
||||
EV.subscribe(EVENTS.downloadCanceled, (data) => {
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.fileDownload,
|
||||
onDownloadingAttachmentProgress
|
||||
),
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.fileUpload,
|
||||
onUploadingAttachmentProgress
|
||||
),
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.fileDownloaded,
|
||||
onDownloadedAttachmentProgress
|
||||
),
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.fileUploaded,
|
||||
onUploadedAttachmentProgress
|
||||
),
|
||||
db.eventManager.subscribe(EVENTS.downloadCanceled, (data) => {
|
||||
useAttachmentStore.getState().setDownloading(data);
|
||||
}),
|
||||
EV.subscribe(EVENTS.uploadCanceled, (data) => {
|
||||
db.eventManager.subscribe(EVENTS.uploadCanceled, (data) => {
|
||||
useAttachmentStore.getState().setUploading(data);
|
||||
}),
|
||||
EV.subscribe(EVENTS.migrationStarted, (name) => {
|
||||
@@ -765,7 +788,7 @@ export const useAppEvents = () => {
|
||||
return;
|
||||
endProgress();
|
||||
}),
|
||||
EV.subscribe(EVENTS.vaultLocked, async () => {
|
||||
db.eventManager.subscribe(EVENTS.vaultLocked, async () => {
|
||||
// Lock all notes in all tabs...
|
||||
for (const tab of useTabStore.getState().tabs) {
|
||||
const noteId = useTabStore.getState().getTab(tab.id)?.session?.noteId;
|
||||
@@ -797,7 +820,6 @@ export const useAppEvents = () => {
|
||||
return () => {
|
||||
emitterSubscriptions.forEach((sub) => sub?.remove?.());
|
||||
subscriptions.forEach((sub) => sub?.unsubscribe?.());
|
||||
EV.unsubscribeAll();
|
||||
};
|
||||
}, [onSyncComplete, onUserUpdated]);
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ export type SyncProgressEventType = {
|
||||
|
||||
const useSyncProgress = () => {
|
||||
const [progress, setProgress] = useState<SyncProgressEventType>();
|
||||
const EV = db.eventManager;
|
||||
|
||||
const onProgress = useCallback(
|
||||
({ type, current, total }: SyncProgressEventType) => {
|
||||
@@ -42,13 +41,13 @@ const useSyncProgress = () => {
|
||||
setProgress(undefined);
|
||||
};
|
||||
useEffect(() => {
|
||||
EV?.subscribe(EVENTS.syncProgress, onProgress);
|
||||
EV?.subscribe(EVENTS.syncCompleted, onSyncComplete);
|
||||
db.eventManager.subscribe(EVENTS.syncProgress, onProgress);
|
||||
db.eventManager.subscribe(EVENTS.syncCompleted, onSyncComplete);
|
||||
return () => {
|
||||
EV?.unsubscribe(EVENTS.syncProgress, onProgress);
|
||||
EV?.unsubscribe(EVENTS.syncCompleted, onSyncComplete);
|
||||
db.eventManager.unsubscribe(EVENTS.syncProgress, onProgress);
|
||||
db.eventManager.unsubscribe(EVENTS.syncCompleted, onSyncComplete);
|
||||
};
|
||||
}, [EV, onProgress]);
|
||||
}, [onProgress]);
|
||||
|
||||
return {
|
||||
progress
|
||||
|
||||
@@ -39,7 +39,7 @@ import Input from "../../components/ui/input";
|
||||
import { ReminderTime } from "../../components/ui/reminder-time";
|
||||
import Paragraph from "../../components/ui/typography/paragraph";
|
||||
import { DDS } from "../../services/device-detection";
|
||||
import { ToastManager } from "../../services/event-manager";
|
||||
import { eSendEvent, ToastManager } from "../../services/event-manager";
|
||||
import Navigation, { NavigationProps } from "../../services/navigation";
|
||||
import Notifications from "../../services/notifications";
|
||||
import SettingsService from "../../services/settings";
|
||||
@@ -47,9 +47,18 @@ import { useRelationStore } from "../../stores/use-relation-store";
|
||||
import { useSettingStore } from "../../stores/use-setting-store";
|
||||
import { AppFontSize, defaultBorderRadius } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { getFormattedDate, useIsFeatureAvailable } from "@notesnook/common";
|
||||
import {
|
||||
getFormattedDate,
|
||||
useIsFeatureAvailable,
|
||||
usePromise
|
||||
} from "@notesnook/common";
|
||||
import PaywallSheet from "../../components/sheets/paywall";
|
||||
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
|
||||
import { Pressable } from "../../components/ui/pressable";
|
||||
import { TimeSince } from "../../components/ui/time-since";
|
||||
import Heading from "../../components/ui/typography/heading";
|
||||
import { eOnLoadNote } from "../../utils/events";
|
||||
import { fluidTabsRef } from "../../utils/global-refs";
|
||||
|
||||
const ReminderModes =
|
||||
Platform.OS === "ios"
|
||||
@@ -114,6 +123,15 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
const titleRef = useRef<TextInput>(null);
|
||||
const descriptionRef = useRef<TextInput>(null);
|
||||
const timer = useRef<NodeJS.Timeout>(undefined);
|
||||
const referencedNotes = usePromise(
|
||||
() =>
|
||||
reminder?.id
|
||||
? db.relations
|
||||
.to({ id: reminder.id, type: "reminder" }, "note")
|
||||
.resolve()
|
||||
: null,
|
||||
[reminder?.id]
|
||||
);
|
||||
|
||||
const showDatePicker = () => {
|
||||
setDatePickerVisibility(true);
|
||||
@@ -237,6 +255,9 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
marginBottom: DDS.isTab ? 25 : undefined,
|
||||
paddingHorizontal: DefaultAppStyles.GAP
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
gap: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -271,15 +292,11 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
height={80}
|
||||
wrapperStyle={{
|
||||
marginBottom: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
marginBottom: DefaultAppStyles.GAP_VERTICAL
|
||||
flexDirection: "row"
|
||||
}}
|
||||
horizontal
|
||||
>
|
||||
@@ -335,8 +352,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
style={{
|
||||
backgroundColor: colors.secondary.background,
|
||||
padding: DefaultAppStyles.GAP,
|
||||
borderRadius: defaultBorderRadius,
|
||||
marginBottom: DefaultAppStyles.GAP_VERTICAL
|
||||
borderRadius: defaultBorderRadius
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -454,7 +470,6 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
marginBottom: DefaultAppStyles.GAP_VERTICAL,
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
@@ -513,10 +528,8 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
style={{
|
||||
borderRadius: defaultBorderRadius,
|
||||
flexDirection: "row",
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
marginBottom: DefaultAppStyles.GAP_VERTICAL
|
||||
justifyContent: "flex-start"
|
||||
}}
|
||||
>
|
||||
<>
|
||||
@@ -552,22 +565,10 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ReminderTime
|
||||
reminder={reminder}
|
||||
style={{
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
borderWidth: 0,
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
alignSelf: "flex-start"
|
||||
}}
|
||||
/>
|
||||
|
||||
{reminderMode === ReminderModes.Permanent ? null : (
|
||||
<ScrollView
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
height: 50
|
||||
}}
|
||||
horizontal
|
||||
@@ -612,6 +613,60 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<ReminderTime
|
||||
reminder={reminder}
|
||||
style={{
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
alignSelf: "flex-start"
|
||||
}}
|
||||
/>
|
||||
|
||||
{referencedNotes &&
|
||||
referencedNotes.status === "fulfilled" &&
|
||||
referencedNotes.value !== null &&
|
||||
referencedNotes.value?.length > 0 ? (
|
||||
<View
|
||||
style={{
|
||||
gap: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
>
|
||||
<Heading size={AppFontSize.md}>{strings.referencedIn()}</Heading>
|
||||
{referencedNotes.value.map((item) => (
|
||||
<Pressable
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: DefaultAppStyles.GAP,
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
onPress={() => {
|
||||
Navigation.navigate("FluidPanelsView");
|
||||
fluidTabsRef.current?.goToPage("editor");
|
||||
eSendEvent(eOnLoadNote, {
|
||||
item: item
|
||||
});
|
||||
}}
|
||||
type="secondary"
|
||||
>
|
||||
<Paragraph>{item.title}</Paragraph>
|
||||
<TimeSince
|
||||
style={{
|
||||
fontSize: AppFontSize.xxs,
|
||||
color: colors.secondary.paragraph,
|
||||
marginRight: 6
|
||||
}}
|
||||
time={item.dateEdited}
|
||||
updateFrequency={
|
||||
Date.now() - item.dateEdited < 60000 ? 2000 : 60000
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</KeyboardViewIOS>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -183,6 +183,7 @@ const Editor = React.memo(
|
||||
hideKeyboardAccessoryView={false}
|
||||
allowsFullscreenVideo={true}
|
||||
allowFileAccessFromFileURLs={true}
|
||||
allowsInlineMediaPlayback
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
originWhitelist={["*"]}
|
||||
source={{
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
KeepLocalCopyResponse,
|
||||
pick as pickFile
|
||||
} from "@react-native-documents/picker";
|
||||
import { basename } from "pathe";
|
||||
import { basename, dirname } from "pathe";
|
||||
import { Platform } from "react-native";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { Image, openCamera, openPicker } from "react-native-image-crop-picker";
|
||||
@@ -119,41 +119,30 @@ const file = async (fileOptions: PickerOptions) => {
|
||||
throw new Error("Failed to attach file");
|
||||
}
|
||||
|
||||
await RNFetchBlob.fs.unlink(uri);
|
||||
RNFetchBlob.fs.unlink(dirname(fileCopyUri.localUri)).catch((e) => {
|
||||
console.log(e, "error");
|
||||
});
|
||||
|
||||
if (
|
||||
fileOptions.tabId !== undefined &&
|
||||
useTabStore.getState().getNoteIdForTab(fileOptions.tabId) ===
|
||||
fileOptions.noteId
|
||||
) {
|
||||
if (isImage(file.type || "application/octet-stream")) {
|
||||
editorController.current?.commands.insertImage(
|
||||
{
|
||||
hash: hash,
|
||||
filename: fileName,
|
||||
mime: file.type || "application/octet-stream",
|
||||
size: file.size || 0,
|
||||
dataurl: (await db.attachments.read(hash, "base64")) as string,
|
||||
type: "image"
|
||||
},
|
||||
fileOptions.tabId
|
||||
);
|
||||
} else {
|
||||
editorController.current?.commands.insertAttachment(
|
||||
{
|
||||
hash: hash,
|
||||
filename: fileName,
|
||||
mime: file.type || "application/octet-stream",
|
||||
size: file.size || 0,
|
||||
type: "file"
|
||||
},
|
||||
fileOptions.tabId
|
||||
);
|
||||
}
|
||||
editorController.current?.commands.insertAttachment(
|
||||
{
|
||||
hash: hash,
|
||||
filename: fileName,
|
||||
mime: file.type || "application/octet-stream",
|
||||
size: file.size || 0,
|
||||
type: "file"
|
||||
},
|
||||
fileOptions.tabId
|
||||
);
|
||||
} else {
|
||||
throw new Error("Failed to attach file, no tabId is set");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("ERROR OCCURED HERE.", e);
|
||||
ToastManager.show({
|
||||
heading: (e as Error).message,
|
||||
type: "error",
|
||||
@@ -264,7 +253,6 @@ const handleImageResponse = async (
|
||||
for (const image of response) {
|
||||
const isPng = /(png)/g.test(image.mime);
|
||||
const isJpeg = /(jpeg|jpg)/g.test(image.mime);
|
||||
|
||||
if (compress && (isPng || isJpeg)) {
|
||||
image.path = await compressToFile(
|
||||
Platform.OS === "ios" ? "file://" + image.path : image.path,
|
||||
@@ -304,7 +292,7 @@ const handleImageResponse = async (
|
||||
|
||||
if (!(await attachFile(uri, hash, image.mime, fileName, options))) return;
|
||||
|
||||
if (Platform.OS === "ios") await RNFetchBlob.fs.unlink(uri);
|
||||
RNFetchBlob.fs.unlink(uri).catch((e) => {});
|
||||
|
||||
if (
|
||||
options.tabId !== undefined &&
|
||||
@@ -389,9 +377,7 @@ export async function attachFile(
|
||||
return true;
|
||||
} catch (e) {
|
||||
DatabaseLogger.error(e);
|
||||
if (Platform.OS === "ios") {
|
||||
await RNFetchBlob.fs.unlink(uri);
|
||||
}
|
||||
RNFetchBlob.fs.unlink(uri).catch((e) => {});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,15 +534,24 @@ export const useEditorEvents = (
|
||||
}
|
||||
|
||||
case EditorEvents.getAttachmentData: {
|
||||
const attachment = (editorMessage.value as any)
|
||||
?.attachment as Attachment;
|
||||
const data = (editorMessage.value as any)?.attachment as Attachment;
|
||||
|
||||
const attachment = await db.attachments.attachment(data.hash);
|
||||
|
||||
if (!attachment) {
|
||||
editor.postMessage(NativeEvents.resolve, {
|
||||
resolverId: editorMessage.resolverId,
|
||||
data: undefined
|
||||
});
|
||||
break;
|
||||
}
|
||||
DatabaseLogger.log(
|
||||
`Getting attachment data: ${attachment?.hash} ${attachment?.type}`
|
||||
`Getting attachment data: ${attachment.mimeType} ${attachment.hash} ${data.type}`
|
||||
);
|
||||
downloadAttachment(attachment.hash, true, {
|
||||
base64: attachment.type === "image",
|
||||
text: attachment.type === "web-clip",
|
||||
base64:
|
||||
data.type === "image" || attachment.mimeType?.startsWith("audio"),
|
||||
text: data.type === "web-clip",
|
||||
silent: true,
|
||||
groupId: editor.note.current?.id,
|
||||
cache: true
|
||||
@@ -550,7 +559,7 @@ export const useEditorEvents = (
|
||||
.then((data: any) => {
|
||||
console.log(
|
||||
"Got attachment data:",
|
||||
!!data,
|
||||
data,
|
||||
editorMessage.resolverId
|
||||
);
|
||||
editor.postMessage(NativeEvents.resolve, {
|
||||
|
||||
@@ -1032,13 +1032,6 @@ export const useEditor = (
|
||||
state.current.isRestoringState = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
eSubscribeEvent(eOnLoadNote + editorId, loadNote);
|
||||
return () => {
|
||||
eUnSubscribeEvent(eOnLoadNote + editorId, loadNote);
|
||||
};
|
||||
}, [editorId, loadNote, restoreEditorState, isDefaultEditor]);
|
||||
|
||||
const onContentChanged = (noteId?: string) => {
|
||||
if (noteId) {
|
||||
lastContentChangeTime.current[noteId] = Date.now();
|
||||
|
||||
@@ -17,24 +17,22 @@ 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 { VirtualizedGrouping } from "@notesnook/core";
|
||||
import { sanitizeTag } from "@notesnook/core";
|
||||
import { Tag } from "@notesnook/core";
|
||||
import { LegendList } from "@legendapp/list";
|
||||
import { isFeatureAvailable } from "@notesnook/common";
|
||||
import { Tag, VirtualizedGrouping, sanitizeTag } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { TextInput, View, useWindowDimensions } from "react-native";
|
||||
import { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../common/database";
|
||||
import { useDBItem } from "../../hooks/use-db-item";
|
||||
import { Header } from "../../components/header";
|
||||
import Input from "../../components/ui/input";
|
||||
import { Pressable } from "../../components/ui/pressable";
|
||||
import Heading from "../../components/ui/typography/heading";
|
||||
import Paragraph from "../../components/ui/typography/paragraph";
|
||||
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
|
||||
import { ToastManager } from "../../services/event-manager";
|
||||
import Navigation, { NavigationProps } from "../../services/navigation";
|
||||
import {
|
||||
@@ -43,17 +41,8 @@ import {
|
||||
} from "../../stores/item-selection-store";
|
||||
import { useRelationStore } from "../../stores/use-relation-store";
|
||||
import { useTagStore } from "../../stores/use-tag-store";
|
||||
import { defaultBorderRadius, AppFontSize } from "../../utils/size";
|
||||
import Input from "../../components/ui/input";
|
||||
import { Pressable } from "../../components/ui/pressable";
|
||||
import Heading from "../../components/ui/typography/heading";
|
||||
import Paragraph from "../../components/ui/typography/paragraph";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { AppFontSize, defaultBorderRadius } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { Header } from "../../components/header";
|
||||
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
|
||||
import { isFeatureAvailable } from "@notesnook/common";
|
||||
import { LegendList } from "@legendapp/list";
|
||||
|
||||
async function updateInitialSelectionState(items: string[]) {
|
||||
const relations = await db.relations
|
||||
@@ -92,13 +81,12 @@ const useTagItemSelection = createItemSelectionStore(true);
|
||||
const ManageTags = (props: NavigationProps<"ManageTags">) => {
|
||||
const { colors } = useThemeColors();
|
||||
const ids = props.route.params.ids || [];
|
||||
const [tags, setTags] = useState<VirtualizedGrouping<Tag>>();
|
||||
const [tags, setTags] = useState<Tag[]>();
|
||||
const [query, setQuery] = useState<string>();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [focus, setFocus] = useState(false);
|
||||
useNavigationFocus(props.navigation, { focusOnInit: true });
|
||||
const timerRef = useRef<NodeJS.Timeout>(undefined);
|
||||
const [queryExists, setQueryExists] = useState(false);
|
||||
const dimensions = useWindowDimensions();
|
||||
const refreshSelection = useCallback(() => {
|
||||
updateInitialSelectionState(ids).then((selection) => {
|
||||
useTagItemSelection.setState({
|
||||
@@ -109,18 +97,43 @@ const ManageTags = (props: NavigationProps<"ManageTags">) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ids, tags]);
|
||||
|
||||
const sortAndSetTags = useCallback(
|
||||
async (items: VirtualizedGrouping<Tag>) => {
|
||||
const tags = [];
|
||||
const noteTags = [];
|
||||
const assignedTags =
|
||||
ids.length > 1
|
||||
? []
|
||||
: await db.relations
|
||||
.to({ type: "note", id: ids[0] }, "tag")
|
||||
.resolve();
|
||||
|
||||
for (let i = 0; i < items.placeholders.length; i++) {
|
||||
const item = (await items.item(i)).item;
|
||||
if (item) {
|
||||
if (assignedTags.find((tag) => tag.id === item.id)) {
|
||||
noteTags.push(item);
|
||||
} else {
|
||||
tags.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
tags.splice(0, 0, ...noteTags);
|
||||
setTags(tags);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const refreshTags = useCallback(() => {
|
||||
if (query && query.trim() !== "") {
|
||||
db.lookup
|
||||
.tags(query)
|
||||
.sorted(db.settings.getGroupOptions("tags"))
|
||||
.then((items) => {
|
||||
setTags(items);
|
||||
});
|
||||
.then(sortAndSetTags);
|
||||
} else {
|
||||
db.tags.all.sorted(db.settings.getGroupOptions("tags")).then((items) => {
|
||||
setTags(items);
|
||||
});
|
||||
db.tags.all
|
||||
.sorted(db.settings.getGroupOptions("tags"))
|
||||
.then(sortAndSetTags);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
@@ -241,14 +254,10 @@ const ManageTags = (props: NavigationProps<"ManageTags">) => {
|
||||
);
|
||||
|
||||
const renderTag = useCallback(
|
||||
({ index }: { item: boolean; index: number }) => (
|
||||
<TagItem
|
||||
tags={tags as VirtualizedGrouping<Tag>}
|
||||
id={index}
|
||||
onPress={onPress}
|
||||
/>
|
||||
({ index, item }: { item: Tag; index: number }) => (
|
||||
<TagItem tag={item} onPress={onPress} />
|
||||
),
|
||||
[onPress, tags]
|
||||
[onPress]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -280,14 +289,11 @@ const ManageTags = (props: NavigationProps<"ManageTags">) => {
|
||||
fwdRef={inputRef}
|
||||
autoCapitalize="none"
|
||||
onChangeText={(v) => {
|
||||
setQuery(sanitizeTag(v));
|
||||
checkQueryExists(sanitizeTag(v));
|
||||
}}
|
||||
onFocusInput={() => {
|
||||
setFocus(true);
|
||||
}}
|
||||
onBlurInput={() => {
|
||||
setFocus(false);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setQuery(sanitizeTag(v));
|
||||
checkQueryExists(sanitizeTag(v));
|
||||
}, 300);
|
||||
}}
|
||||
onSubmit={() => {
|
||||
onSubmit();
|
||||
@@ -324,7 +330,7 @@ const ManageTags = (props: NavigationProps<"ManageTags">) => {
|
||||
}}
|
||||
>
|
||||
<LegendList
|
||||
data={tags?.placeholders || []}
|
||||
data={tags || []}
|
||||
extraData={tags}
|
||||
keyboardShouldPersistTaps
|
||||
keyboardDismissMode="interactive"
|
||||
@@ -367,16 +373,13 @@ ManageTags.present = (ids?: string[]) => {
|
||||
export default ManageTags;
|
||||
|
||||
const TagItem = ({
|
||||
id,
|
||||
tags,
|
||||
onPress
|
||||
onPress,
|
||||
tag
|
||||
}: {
|
||||
id: string | number;
|
||||
tags: VirtualizedGrouping<Tag>;
|
||||
tag: Tag;
|
||||
onPress: (id: string) => void;
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
const [tag] = useDBItem(id, "tag", tags);
|
||||
const selection = useTagItemSelection((state) =>
|
||||
tag?.id ? state.selection[tag?.id] : false
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export const NotePreviewConfigure = () => {
|
||||
useEffect(() => {
|
||||
useSettingStore.getState().setDeviceMode("mobile");
|
||||
if (loading) return;
|
||||
db.notes.all.sorted(db.settings.getGroupOptions("notes")).then((notes) => {
|
||||
db.notes.all.sorted(db.settings.getGroupOptions("home")).then((notes) => {
|
||||
setItems(notes);
|
||||
});
|
||||
}, [loading]);
|
||||
@@ -141,7 +141,7 @@ export const NotePreviewConfigure = () => {
|
||||
bounceRef.current = setTimeout(() => {
|
||||
if (!value) {
|
||||
db.notes.all
|
||||
.sorted(db.settings.getGroupOptions("notes"))
|
||||
.sorted(db.settings.getGroupOptions("home"))
|
||||
.then((notes) => {
|
||||
setItems(notes);
|
||||
});
|
||||
@@ -149,7 +149,7 @@ export const NotePreviewConfigure = () => {
|
||||
}
|
||||
db.lookup
|
||||
.notes(value)
|
||||
.sorted()
|
||||
.sorted(db.settings.getGroupOptions("home"))
|
||||
.then((notes) => {
|
||||
setItems(notes);
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ const Home = ({
|
||||
/>
|
||||
<DelayLayout type="settings">
|
||||
<LegendList
|
||||
testID="settings-list"
|
||||
data={settingsGroups}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
|
||||
@@ -53,7 +53,8 @@ import {
|
||||
eSendEvent,
|
||||
eSubscribeEvent,
|
||||
openVault,
|
||||
presentSheet
|
||||
presentSheet,
|
||||
VaultRequestType
|
||||
} from "../../services/event-manager";
|
||||
import Navigation from "../../services/navigation";
|
||||
import Notifications from "../../services/notifications";
|
||||
@@ -62,7 +63,11 @@ import SettingsService from "../../services/settings";
|
||||
import Sync from "../../services/sync";
|
||||
import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { useUserStore } from "../../stores/use-user-store";
|
||||
import { eCloseSheet, eOpenRecoveryKeyDialog } from "../../utils/events";
|
||||
import {
|
||||
eAfterSync,
|
||||
eCloseSheet,
|
||||
eOpenRecoveryKeyDialog
|
||||
} from "../../utils/events";
|
||||
import { NotesnookModule } from "../../utils/notesnook-module";
|
||||
import { sleep } from "../../utils/time";
|
||||
import { MFARecoveryCodes, MFASheet } from "./2fa";
|
||||
@@ -72,8 +77,49 @@ import { logoutUser } from "./logout";
|
||||
import { SettingSection } from "./types";
|
||||
import { getTimeLeft } from "./user-section";
|
||||
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
|
||||
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";
|
||||
|
||||
export const settingsGroups: SettingSection[] = [
|
||||
{
|
||||
id: "account-local",
|
||||
name: strings.account(),
|
||||
useHook: () => useUserStore((state) => state.user),
|
||||
hidden: (current) => !!current,
|
||||
sections: [
|
||||
{
|
||||
id: "delete-data",
|
||||
name: strings.deleteData(),
|
||||
description: strings.deleteAccountDesc(),
|
||||
modifer: () => {
|
||||
presentDialog({
|
||||
title: strings.deleteData(),
|
||||
paragraph: strings.irreverisibleAction(),
|
||||
positiveType: "errorShade",
|
||||
positiveText: "Delete data",
|
||||
positivePress: async () => {
|
||||
await PremiumService.setPremiumStatus();
|
||||
await BiometricService.resetCredentials();
|
||||
MMKV.clearStore();
|
||||
resetTabStore();
|
||||
clearAllStores();
|
||||
Navigation.queueRoutesForUpdate();
|
||||
SettingsService.resetSettings();
|
||||
db.reset();
|
||||
|
||||
setImmediate(() => {
|
||||
refreshAllStores();
|
||||
eSendEvent(eAfterSync);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
name: strings.account(),
|
||||
@@ -286,17 +332,10 @@ export const settingsGroups: SettingSection[] = [
|
||||
{
|
||||
id: "change-password",
|
||||
name: strings.changePassword(),
|
||||
// type: "screen",
|
||||
type: "screen",
|
||||
description: strings.changePasswordDesc(),
|
||||
// component: "change-password",
|
||||
icon: "form-textbox-password",
|
||||
modifer: () => {
|
||||
ToastManager.show({
|
||||
type: "info",
|
||||
message:
|
||||
"Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved."
|
||||
});
|
||||
}
|
||||
component: "change-password",
|
||||
icon: "form-textbox-password"
|
||||
},
|
||||
{
|
||||
id: "change-email",
|
||||
@@ -929,8 +968,9 @@ export const settingsGroups: SettingSection[] = [
|
||||
hidden: (current) => (current as VaultStatusType)?.exists,
|
||||
modifer: () => {
|
||||
openVault({
|
||||
novault: false,
|
||||
title: strings.createVault()
|
||||
requestType: VaultRequestType.CreateVault,
|
||||
title: strings.createVault(),
|
||||
buttonTitle: strings.create()
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -942,9 +982,9 @@ export const settingsGroups: SettingSection[] = [
|
||||
hidden: (current) => !(current as VaultStatusType)?.exists,
|
||||
modifer: () =>
|
||||
openVault({
|
||||
changePassword: true,
|
||||
novault: true,
|
||||
title: strings.changeVaultPassword()
|
||||
requestType: VaultRequestType.ChangePassword,
|
||||
title: strings.changeVaultPassword(),
|
||||
buttonTitle: strings.change()
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -955,9 +995,10 @@ export const settingsGroups: SettingSection[] = [
|
||||
hidden: (current) => !(current as VaultStatusType)?.exists,
|
||||
modifer: () => {
|
||||
openVault({
|
||||
clearVault: true,
|
||||
novault: true,
|
||||
title: strings.clearVault() + "?"
|
||||
requestType: VaultRequestType.ClearVault,
|
||||
title: strings.clearVault() + "?",
|
||||
buttonTitle: strings.clear(),
|
||||
positiveButtonType: "errorShade"
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -969,9 +1010,10 @@ export const settingsGroups: SettingSection[] = [
|
||||
hidden: (current) => !(current as VaultStatusType)?.exists,
|
||||
modifer: () => {
|
||||
openVault({
|
||||
deleteVault: true,
|
||||
novault: true,
|
||||
title: strings.deleteVault() + "?"
|
||||
requestType: VaultRequestType.DeleteVault,
|
||||
title: strings.deleteVault() + "?",
|
||||
buttonTitle: strings.delete(),
|
||||
positiveButtonType: "errorShade"
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -989,13 +1031,15 @@ export const settingsGroups: SettingSection[] = [
|
||||
getter: (current) => (current as VaultStatusType)?.biometryEnrolled,
|
||||
modifer: (current) => {
|
||||
const _current = current as VaultStatusType;
|
||||
const isRevoking = _current.biometryEnrolled;
|
||||
openVault({
|
||||
fingerprintAccess: !_current.biometryEnrolled,
|
||||
revokeFingerprintAccess: _current.biometryEnrolled,
|
||||
novault: true,
|
||||
title: _current.biometryEnrolled
|
||||
requestType: isRevoking
|
||||
? VaultRequestType.RevokeFingerprint
|
||||
: VaultRequestType.EnableFingerprint,
|
||||
title: isRevoking
|
||||
? strings.revokeBiometricUnlock()
|
||||
: strings.vaultEnableBiometrics()
|
||||
: strings.vaultEnableBiometrics(),
|
||||
buttonTitle: isRevoking ? strings.revoke() : strings.enable()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,25 +32,34 @@ import {
|
||||
} from "../utils/events";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
export enum VaultRequestType {
|
||||
CreateVault = "createVault",
|
||||
LockNote = "lockNote",
|
||||
UnlockNote = "unlockNote",
|
||||
PermanentUnlock = "permanentUnlock",
|
||||
GoToEditor = "goToEditor",
|
||||
ShareNote = "shareNote",
|
||||
CopyNote = "copyNote",
|
||||
DeleteNote = "deleteNote",
|
||||
EnableFingerprint = "enableFingerprint",
|
||||
RevokeFingerprint = "revokeFingerprint",
|
||||
ChangePassword = "changePassword",
|
||||
ClearVault = "clearVault",
|
||||
DeleteVault = "deleteVault",
|
||||
CustomAction = "customAction"
|
||||
}
|
||||
|
||||
export type Vault = {
|
||||
item: Note;
|
||||
novault: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
locked: boolean;
|
||||
permanant: boolean;
|
||||
goToEditor: boolean;
|
||||
share: boolean;
|
||||
deleteNote: boolean;
|
||||
fingerprintAccess: boolean;
|
||||
revokeFingerprintAccess: boolean;
|
||||
changePassword: boolean;
|
||||
clearVault: boolean;
|
||||
deleteVault: boolean;
|
||||
copyNote: boolean;
|
||||
customActionTitle: string;
|
||||
customActionParagraph: string;
|
||||
onUnlock: (
|
||||
item?: Note;
|
||||
requestType: VaultRequestType;
|
||||
title?: string;
|
||||
description?: string;
|
||||
paragraph?: string;
|
||||
buttonTitle?: string;
|
||||
positiveButtonType?: "errorShade" | "transparent" | "accent";
|
||||
customActionTitle?: string;
|
||||
customActionParagraph?: string;
|
||||
onUnlock?: (
|
||||
item: Note & {
|
||||
content?: NoteContent<false>;
|
||||
},
|
||||
@@ -87,7 +96,7 @@ export const eSendEvent = (eventName: string, ...args: any[]) => {
|
||||
eventManager.publish(eventName, ...args);
|
||||
};
|
||||
|
||||
export const openVault = (data: Partial<Vault>) => {
|
||||
export const openVault = (data: Vault) => {
|
||||
eSendEvent(eOpenVaultDialog, data);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { expect } from "detox";
|
||||
import { notesnook } from "../test.ids";
|
||||
import { TestBuilder, Tests } from "./utils";
|
||||
import { Element, TestBuilder, Tests } from "./utils";
|
||||
|
||||
async function lockNote() {
|
||||
await TestBuilder.create()
|
||||
@@ -67,6 +67,11 @@ async function goToPrivacySecuritySettings() {
|
||||
.waitAndTapById("sidemenu-settings-icon")
|
||||
.wait()
|
||||
.waitAndTapByText("Settings")
|
||||
.addStep(async () => {
|
||||
const element = new Element("id", "settings-list");
|
||||
await element.element.scroll(200, "down");
|
||||
})
|
||||
.wait(500)
|
||||
.waitAndTapByText("Vault")
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -1029,7 +1029,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2171;
|
||||
CURRENT_PROJECT_VERSION = 2174;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1104,7 +1104,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.13;
|
||||
MARKETING_VERSION = 3.3.15;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -1135,7 +1135,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 2171;
|
||||
CURRENT_PROJECT_VERSION = 2174;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1210,7 +1210,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.13;
|
||||
MARKETING_VERSION = 3.3.15;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -1367,7 +1367,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2171;
|
||||
CURRENT_PROJECT_VERSION = 2174;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1379,7 +1379,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.13;
|
||||
MARKETING_VERSION = 3.3.15;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
|
||||
@@ -1410,7 +1410,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2171;
|
||||
CURRENT_PROJECT_VERSION = 2174;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
|
||||
@@ -1423,7 +1423,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.3.13;
|
||||
MARKETING_VERSION = 3.3.15;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -1453,7 +1453,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Make Note/Make Note.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2171;
|
||||
CURRENT_PROJECT_VERSION = 2174;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1534,7 +1534,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(inherited)";
|
||||
MARKETING_VERSION = 3.3.13;
|
||||
MARKETING_VERSION = 3.3.15;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
|
||||
@@ -1565,7 +1565,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2171;
|
||||
CURRENT_PROJECT_VERSION = 2174;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 53CWBG3QUC;
|
||||
@@ -1647,7 +1647,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift$(inherited)";
|
||||
MARKETING_VERSION = 3.3.13;
|
||||
MARKETING_VERSION = 3.3.15;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
12
apps/mobile/package-lock.json
generated
12
apps/mobile/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.13-beta.1",
|
||||
"version": "3.3.15-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.13-beta.1",
|
||||
"version": "3.3.15-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"@ammarahmed/react-native-fingerprint-scanner": "^5.0.1",
|
||||
"@ammarahmed/react-native-share-extension": "^2.9.5",
|
||||
"@ammarahmed/react-native-sodium": "^1.6.8",
|
||||
"@ammarahmed/react-native-upload": "^6.31.0",
|
||||
"@ammarahmed/react-native-upload": "^6.32.0",
|
||||
"@azure/core-asynciterator-polyfill": "^1.0.2",
|
||||
"@bam.tech/react-native-image-resizer": "3.0.11",
|
||||
"@callstack/repack": "~5.2.1",
|
||||
@@ -538,9 +538,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@ammarahmed/react-native-upload": {
|
||||
"version": "6.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@ammarahmed/react-native-upload/-/react-native-upload-6.31.0.tgz",
|
||||
"integrity": "sha512-GwMDd2IND3nuS4l13pQPP8zA/GJoxQKz0pxCGUoWj65fbPuZPnuUA2Rpf4jZzLlgY62vdjI/TqmX+rPiwwCceQ==",
|
||||
"version": "6.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@ammarahmed/react-native-upload/-/react-native-upload-6.32.0.tgz",
|
||||
"integrity": "sha512-rb14iQPDFKSefcqdeak2uXg4pOTYPYMn4IpPnODOhKt+ju/nqO7mx2pvPi623z2J/+mJ5qOpaFMK8AS65K5jyw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.13",
|
||||
"version": "3.3.15-beta.1",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
@@ -32,7 +32,7 @@
|
||||
"@ammarahmed/react-native-fingerprint-scanner": "^5.0.1",
|
||||
"@ammarahmed/react-native-share-extension": "^2.9.5",
|
||||
"@ammarahmed/react-native-sodium": "^1.6.8",
|
||||
"@ammarahmed/react-native-upload": "^6.31.0",
|
||||
"@ammarahmed/react-native-upload": "^6.32.0",
|
||||
"@azure/core-asynciterator-polyfill": "^1.0.2",
|
||||
"@bam.tech/react-native-image-resizer": "3.0.11",
|
||||
"@callstack/repack": "~5.2.1",
|
||||
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
fillColorDialog,
|
||||
fillNotebookDialog,
|
||||
fillPasswordDialog,
|
||||
fillReminderDialog,
|
||||
iterateList
|
||||
} from "./utils";
|
||||
import { SessionHistoryItemModel } from "./session-history-item-model";
|
||||
import dayjs from "dayjs";
|
||||
import { Reminder } from "@notesnook/core";
|
||||
|
||||
abstract class BaseProperties {
|
||||
protected readonly page: Page;
|
||||
@@ -388,6 +390,12 @@ export class NoteContextMenuModel extends BaseProperties {
|
||||
await confirmDialog(dialog);
|
||||
}
|
||||
|
||||
async addReminder(reminder: Partial<Reminder>) {
|
||||
await this.open();
|
||||
await this.menu.clickOnItem("remind-me");
|
||||
await fillReminderDialog(this.page, reminder);
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.menu.open(this.noteLocator);
|
||||
}
|
||||
|
||||
@@ -62,4 +62,9 @@ export class ReminderItemModel extends BaseItemModel {
|
||||
await this.contextMenu.open(this.locator);
|
||||
await this.contextMenu.clickOnItem("toggle");
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.contextMenu.open(this.locator);
|
||||
await this.contextMenu.clickOnItem("edit");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { Reminder } from "@notesnook/core";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { AppModel } from "./models/app.model";
|
||||
import { getTestId } from "./utils";
|
||||
|
||||
const ONE_TIME_REMINDER: Partial<Reminder> = {
|
||||
title: "Test reminder 1",
|
||||
@@ -197,3 +198,26 @@ test("editing a weekly recurring reminder should not revert it to daily", async
|
||||
expect(await reminder?.getRecurringMode()).toBe("Weekly");
|
||||
expect(await reminder?.getDescription()).toBe("An edited reminder");
|
||||
});
|
||||
|
||||
test("adding a reminder via note context menu should show reference in edit dialog", async ({
|
||||
page
|
||||
}) => {
|
||||
const app = new AppModel(page);
|
||||
await app.goto();
|
||||
const notes = await app.goToNotes();
|
||||
const note = await notes.createNote({
|
||||
title: "Test note",
|
||||
content: "I am a note"
|
||||
});
|
||||
|
||||
await note?.contextMenu.addReminder(ONE_TIME_REMINDER);
|
||||
const reminders = await app.goToReminders();
|
||||
const reminder = await reminders.findReminder({
|
||||
title: ONE_TIME_REMINDER.title
|
||||
});
|
||||
await reminder?.open();
|
||||
|
||||
const noteReferences = page.locator(getTestId("reminder-note-references"));
|
||||
await noteReferences.waitFor({ state: "visible" });
|
||||
expect(noteReferences.getByText("Test note")).toBeVisible();
|
||||
});
|
||||
|
||||
182
apps/web/__tests__/customize-toolbar.test.ts
Normal file
182
apps/web/__tests__/customize-toolbar.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
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 {
|
||||
Item,
|
||||
Group,
|
||||
Subgroup,
|
||||
TreeNode,
|
||||
moveItem
|
||||
} from "../src/dialogs/settings/components/customize-toolbar";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("moveItem function", () => {
|
||||
it("should correctly set depth when moving item from subgroup to main group", () => {
|
||||
const group: Group = {
|
||||
type: "group",
|
||||
id: "group1",
|
||||
title: "Group 1",
|
||||
depth: 0
|
||||
};
|
||||
|
||||
const subgroup: Subgroup = {
|
||||
type: "group",
|
||||
id: "subgroup1",
|
||||
title: "Subgroup 1",
|
||||
depth: 1
|
||||
};
|
||||
|
||||
const item: Item = {
|
||||
type: "item",
|
||||
id: "item1",
|
||||
title: "Item 1",
|
||||
depth: 2, // currently in subgroup
|
||||
toolId: "bold",
|
||||
icon: "bold"
|
||||
};
|
||||
|
||||
const items: TreeNode[] = [group, subgroup, item];
|
||||
|
||||
// move item from subgroup to main group
|
||||
const result = moveItem(items, "item1", "group1");
|
||||
|
||||
// find the moved item
|
||||
const movedItem = result.find((i) => i.id === "item1") as Item;
|
||||
|
||||
// the item should now have depth 1 (group depth + 1)
|
||||
expect(movedItem.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("should correctly set depth when moving item from main group to subgroup", () => {
|
||||
const group: Group = {
|
||||
type: "group",
|
||||
id: "group1",
|
||||
title: "Group 1",
|
||||
depth: 0
|
||||
};
|
||||
|
||||
const item: Item = {
|
||||
type: "item",
|
||||
id: "item1",
|
||||
title: "Item 1",
|
||||
depth: 1, // currently in main group
|
||||
toolId: "bold",
|
||||
icon: "bold"
|
||||
};
|
||||
|
||||
const subgroup: Subgroup = {
|
||||
type: "group",
|
||||
id: "subgroup1",
|
||||
title: "Subgroup 1",
|
||||
depth: 1
|
||||
};
|
||||
|
||||
const items: TreeNode[] = [group, item, subgroup];
|
||||
|
||||
// move item from main group to subgroup
|
||||
const result = moveItem(items, "item1", "subgroup1");
|
||||
|
||||
// find the moved item
|
||||
const movedItem = result.find((i) => i.id === "item1") as Item;
|
||||
|
||||
// the item should now have depth 2 (subgroup depth + 1)
|
||||
expect(movedItem.depth).toBe(2);
|
||||
});
|
||||
|
||||
it("should correctly set depth when moving item to another item at same level", () => {
|
||||
const group: Group = {
|
||||
type: "group",
|
||||
id: "group1",
|
||||
title: "Group 1",
|
||||
depth: 0
|
||||
};
|
||||
|
||||
const item1: Item = {
|
||||
type: "item",
|
||||
id: "item1",
|
||||
title: "Item 1",
|
||||
depth: 1,
|
||||
toolId: "bold",
|
||||
icon: "bold"
|
||||
};
|
||||
|
||||
const item2: Item = {
|
||||
type: "item",
|
||||
id: "item2",
|
||||
title: "Item 2",
|
||||
depth: 1,
|
||||
toolId: "italic",
|
||||
icon: "italic"
|
||||
};
|
||||
|
||||
const items: TreeNode[] = [group, item1, item2];
|
||||
|
||||
// move item1 to item2's position
|
||||
const result = moveItem(items, "item1", "item2");
|
||||
|
||||
// find the moved item
|
||||
const movedItem = result.find((i) => i.id === "item1") as Item;
|
||||
|
||||
// the item should maintain the same depth as the target item
|
||||
expect(movedItem.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("should correctly set depth when moving item from subgroup to another subgroup", () => {
|
||||
const group: Group = {
|
||||
type: "group",
|
||||
id: "group1",
|
||||
title: "Group 1",
|
||||
depth: 0
|
||||
};
|
||||
|
||||
const subgroup1: Subgroup = {
|
||||
type: "group",
|
||||
id: "subgroup1",
|
||||
title: "Subgroup 1",
|
||||
depth: 1
|
||||
};
|
||||
|
||||
const item1: Item = {
|
||||
type: "item",
|
||||
id: "item1",
|
||||
title: "Item 1",
|
||||
depth: 2,
|
||||
toolId: "bold",
|
||||
icon: "bold"
|
||||
};
|
||||
|
||||
const subgroup2: Subgroup = {
|
||||
type: "group",
|
||||
id: "subgroup2",
|
||||
title: "Subgroup 2",
|
||||
depth: 1
|
||||
};
|
||||
|
||||
const items: TreeNode[] = [group, subgroup1, item1, subgroup2];
|
||||
|
||||
// move item from subgroup1 to subgroup2
|
||||
const result = moveItem(items, "item1", "subgroup2");
|
||||
|
||||
// find the moved item
|
||||
const movedItem = result.find((i) => i.id === "item1") as Item;
|
||||
|
||||
// the item should now have depth 2 (subgroup depth + 1)
|
||||
expect(movedItem.depth).toBe(2);
|
||||
});
|
||||
});
|
||||
4
apps/web/package-lock.json
generated
4
apps/web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/web",
|
||||
"version": "3.3.8",
|
||||
"version": "3.3.9-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/web",
|
||||
"version": "3.3.8",
|
||||
"version": "3.3.9-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@notesnook/web",
|
||||
"description": "Your private note taking space",
|
||||
"version": "3.3.8",
|
||||
"version": "3.3.9-beta.2",
|
||||
"private": true,
|
||||
"main": "./src/app.js",
|
||||
"homepage": "https://notesnook.com/",
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "./common";
|
||||
import { AppEventManager, AppEvents } from "./common/app-events";
|
||||
import { db } from "./common/db";
|
||||
import { EV, EVENTS } from "@notesnook/core";
|
||||
import { EVENTS } from "@notesnook/core";
|
||||
import { registerKeyMap } from "./common/key-map";
|
||||
import { updateStatus, removeStatus, getStatus } from "./hooks/use-status";
|
||||
import { hashNavigate } from "./navigation";
|
||||
@@ -70,6 +70,8 @@ export default function AppEffects() {
|
||||
await scheduleBackups();
|
||||
await scheduleFullBackups();
|
||||
await scheduleExpiredNotesDeletion();
|
||||
|
||||
db.attachments.removeOrphaned().catch(logger.error);
|
||||
if (useSettingStore.getState().isFullOfflineMode)
|
||||
// NOTE: we deliberately don't await here because we don't want to pause execution.
|
||||
db.attachments.cacheAttachments().catch(logger.error);
|
||||
@@ -113,7 +115,7 @@ export default function AppEffects() {
|
||||
}
|
||||
}
|
||||
|
||||
const fileDownloadEvents = EV.subscribeMulti(
|
||||
const fileDownloadEvents = db.eventManager.subscribeMulti(
|
||||
[EVENTS.fileDownloaded, EVENTS.fileDownload],
|
||||
({ total, current }: { total: number; current: number }) => {
|
||||
handleDownloadUploadProgress("download", total, current);
|
||||
@@ -121,7 +123,7 @@ export default function AppEffects() {
|
||||
null
|
||||
);
|
||||
|
||||
const fileUploadEvents = EV.subscribeMulti(
|
||||
const fileUploadEvents = db.eventManager.subscribeMulti(
|
||||
[EVENTS.fileUploaded, EVENTS.fileUpload],
|
||||
({ total, current }: { total: number; current: number }) => {
|
||||
handleDownloadUploadProgress("upload", total, current);
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
initializeFeatureChecks,
|
||||
isFeatureSupported
|
||||
} from "./utils/feature-check";
|
||||
import { initializeLogger } from "./utils/logger";
|
||||
import { initializeLogger, logger } from "./utils/logger";
|
||||
import { shouldShowWrapped } from "./utils/should-show-wrapped";
|
||||
|
||||
type Route<TProps = null> = {
|
||||
@@ -116,9 +116,12 @@ const sessionExpiryExceptions: Routes[] = [
|
||||
];
|
||||
|
||||
function getRoute(): RouteWithPath<AuthProps> | RouteWithPath {
|
||||
const path = getCurrentPath() as Routes;
|
||||
let path = getCurrentPath() as Routes;
|
||||
// logger.info(`Getting route for path: ${path}`);
|
||||
|
||||
const isAccountRecovery = isAccountRecoveryRoute(path);
|
||||
if (isAccountRecovery) path = "/account/recovery";
|
||||
|
||||
const signup = redirectToRegistration(path);
|
||||
const sessionExpired = isSessionExpired(path);
|
||||
const fallback = fallbackRoute();
|
||||
@@ -135,6 +138,10 @@ function getRoute(): RouteWithPath<AuthProps> | RouteWithPath {
|
||||
return signup || sessionExpired || route || fallback;
|
||||
}
|
||||
|
||||
function isAccountRecoveryRoute(path: Routes): boolean {
|
||||
return path.startsWith("/account/recovery");
|
||||
}
|
||||
|
||||
function fallbackRoute(): RouteWithPath {
|
||||
return { route: routes.default, path: "default" };
|
||||
}
|
||||
@@ -187,7 +194,15 @@ export async function init() {
|
||||
initializeLogger()
|
||||
]);
|
||||
|
||||
return { Component, path, props: route.props };
|
||||
const persistence = isAccountRecoveryRoute(path)
|
||||
? ("memory" as const)
|
||||
: ("db" as const);
|
||||
|
||||
logger.info(
|
||||
`Initializing key store with persistence: ${persistence} for path: ${path}`
|
||||
);
|
||||
|
||||
return { Component, path, props: route.props, persistence };
|
||||
}
|
||||
|
||||
function shouldSkipInitiation() {
|
||||
|
||||
@@ -19,18 +19,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { EventSourcePolyfill as EventSource } from "event-source-polyfill";
|
||||
import { DatabasePersistence, NNStorage } from "../interfaces/storage";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
database,
|
||||
getFeature,
|
||||
getFeatureLimit,
|
||||
isFeatureAvailable
|
||||
} from "@notesnook/common";
|
||||
import { database, getFeature, getFeatureLimit } from "@notesnook/common";
|
||||
import { createDialect } from "./sqlite";
|
||||
import { isFeatureSupported } from "../utils/feature-check";
|
||||
import { generatePassword } from "../utils/password-generator";
|
||||
import { deriveKey, useKeyStore } from "../interfaces/key-store";
|
||||
import {
|
||||
hosts,
|
||||
logManager,
|
||||
SubscriptionPlan,
|
||||
SubscriptionStatus
|
||||
@@ -38,6 +33,11 @@ import {
|
||||
import Config from "../utils/config";
|
||||
import { FileStorage } from "../interfaces/fs";
|
||||
|
||||
function getHostUrl(hostUrl: keyof typeof hosts, defaultUrl: string) {
|
||||
const envValue = import.meta.env[`NN_${hostUrl}`];
|
||||
return envValue || defaultUrl;
|
||||
}
|
||||
|
||||
const db = database;
|
||||
async function initializeDatabase(persistence: DatabasePersistence) {
|
||||
performance.mark("start:initializeDatabase");
|
||||
@@ -49,13 +49,16 @@ async function initializeDatabase(persistence: DatabasePersistence) {
|
||||
}
|
||||
|
||||
db.host({
|
||||
API_HOST: "https://api.notesnook.com",
|
||||
AUTH_HOST: "https://auth.streetwriters.co",
|
||||
SSE_HOST: "https://events.streetwriters.co",
|
||||
ISSUES_HOST: "https://issues.streetwriters.co",
|
||||
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
|
||||
MONOGRAPH_HOST: "https://monogr.ph",
|
||||
NOTESNOOK_HOST: "https://notesnook.com",
|
||||
API_HOST: getHostUrl("API_HOST", "https://api.notesnook.com"),
|
||||
AUTH_HOST: getHostUrl("AUTH_HOST", "https://auth.streetwriters.co"),
|
||||
SSE_HOST: getHostUrl("SSE_HOST", "https://events.streetwriters.co"),
|
||||
ISSUES_HOST: getHostUrl("ISSUES_HOST", "https://issues.streetwriters.co"),
|
||||
SUBSCRIPTIONS_HOST: getHostUrl(
|
||||
"SUBSCRIPTIONS_HOST",
|
||||
"https://subscriptions.streetwriters.co"
|
||||
),
|
||||
MONOGRAPH_HOST: getHostUrl("MONOGRAPH_HOST", "https://monogr.ph"),
|
||||
NOTESNOOK_HOST: getHostUrl("NOTESNOOK_HOST", "https://notesnook.com"),
|
||||
...Config.get("serverUrls", {})
|
||||
});
|
||||
|
||||
@@ -72,7 +75,7 @@ async function initializeDatabase(persistence: DatabasePersistence) {
|
||||
dialect: (name, init) =>
|
||||
createDialect({
|
||||
name: persistence === "memory" ? ":memory:" : name,
|
||||
encrypted: true,
|
||||
encrypted: persistence !== "memory",
|
||||
async: !isFeatureSupported("opfs"),
|
||||
init,
|
||||
multiTab
|
||||
@@ -87,7 +90,10 @@ async function initializeDatabase(persistence: DatabasePersistence) {
|
||||
synchronous: "normal",
|
||||
pageSize: 8192,
|
||||
cacheSize: -32000,
|
||||
password: Buffer.from(databaseKey).toString("hex"),
|
||||
password:
|
||||
persistence === "memory"
|
||||
? undefined
|
||||
: Buffer.from(databaseKey).toString("hex"),
|
||||
skipInitialization: !IS_DESKTOP_APP && multiTab
|
||||
},
|
||||
storage: storage,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { DatabaseSource } from "./sqlite-export";
|
||||
import { createSharedServicePort } from "./shared-service";
|
||||
import type { IDBBatchAtomicVFS } from "./IDBBatchAtomicVFS";
|
||||
import type { AccessHandlePoolVFS } from "./AccessHandlePoolVFS";
|
||||
import { rewriteError } from "../../utils/error";
|
||||
|
||||
type PreparedStatement = {
|
||||
stmt: number;
|
||||
@@ -146,7 +147,7 @@ class _SQLiteWorker {
|
||||
return rows;
|
||||
} catch (e) {
|
||||
if (e instanceof Error || e instanceof SQLiteError)
|
||||
e.message += ` (error exec query: ${sql})`;
|
||||
throw rewriteError(e, `${e.message} (error executing query: ${sql})`);
|
||||
throw e;
|
||||
} finally {
|
||||
await this.sqlite
|
||||
@@ -167,6 +168,8 @@ class _SQLiteWorker {
|
||||
sql: string,
|
||||
parameters?: SQLiteCompatibleType[]
|
||||
): Promise<QueryResult<R>> {
|
||||
if (!this.encrypted && !this.initialized) await this.initialize();
|
||||
|
||||
if (this.encrypted && !sql.startsWith("PRAGMA key")) {
|
||||
await this.waitForDatabase();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { showPasswordDialog } from "../dialogs/password-dialog";
|
||||
import { showToast } from "../utils/toast";
|
||||
import { VAULT_ERRORS } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useStore as useAppStore } from "../stores/app-store";
|
||||
|
||||
class Vault {
|
||||
static async createVault() {
|
||||
@@ -34,6 +35,7 @@ class Vault {
|
||||
},
|
||||
validate: async ({ password }) => {
|
||||
await db.vault.create(password);
|
||||
useAppStore.getState().setIsVaultCreated(true);
|
||||
showToast("success", strings.vaultCreated());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,8 @@ export default function TabsView() {
|
||||
) : null}
|
||||
{arePropertiesVisible &&
|
||||
activeSession &&
|
||||
activeSession.type !== "new" && (
|
||||
activeSession.type !== "new" &&
|
||||
activeSession.type !== "locked" && (
|
||||
<Pane id="properties-pane" initialSize={250} minSize={250}>
|
||||
<Properties sessionId={activeSession.id} />
|
||||
</Pane>
|
||||
@@ -302,7 +303,7 @@ function EditorView({
|
||||
|
||||
const result = await db.vault
|
||||
.decryptContent(item)
|
||||
.catch(() => EV.publish(EVENTS.vaultLocked));
|
||||
.catch(() => db.eventManager.publish(EVENTS.vaultLocked));
|
||||
if (!result) return;
|
||||
editor.updateContent(result.data);
|
||||
} else if (isNote && session.note.title !== item.title) {
|
||||
@@ -570,9 +571,9 @@ export function Editor(props: EditorProps) {
|
||||
onChange={onSave}
|
||||
onDownloadAttachment={(attachment) => saveAttachment(attachment.hash)}
|
||||
onPreviewAttachment={async (data) => {
|
||||
const { hash, type } = data;
|
||||
const { hash, type, mime } = data;
|
||||
const attachment = await db.attachments.attachment(hash);
|
||||
if (attachment && type === "image") {
|
||||
if (attachment && mime.startsWith("image/")) {
|
||||
await previewImageAttachment(attachment);
|
||||
} else if (
|
||||
attachment &&
|
||||
@@ -783,14 +784,21 @@ function DropZone(props: DropZoneProps) {
|
||||
display: "none"
|
||||
}}
|
||||
onDrop={async (e) => {
|
||||
const { activeEditorId, getEditor } = useEditorManager.getState();
|
||||
const editor = getEditor(activeEditorId || "")?.editor;
|
||||
if (!e.dataTransfer.files?.length || !editor) return;
|
||||
try {
|
||||
const { activeEditorId, getEditor } = useEditorManager.getState();
|
||||
const editor = getEditor(activeEditorId || "")?.editor;
|
||||
if (!e.dataTransfer.files?.length || !editor) return;
|
||||
|
||||
e.preventDefault();
|
||||
const attachments = await attachFiles(Array.from(e.dataTransfer.files));
|
||||
for (const attachment of attachments || []) {
|
||||
editor.attachFile(attachment);
|
||||
e.preventDefault();
|
||||
const attachments = await attachFiles(
|
||||
Array.from(e.dataTransfer.files)
|
||||
);
|
||||
for (const attachment of attachments || []) {
|
||||
editor.attachFile(attachment);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e as Error, "Failed to attach file from drag and drop");
|
||||
showToast("error", strings.failedToAttachFile());
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -42,10 +42,13 @@ export async function insertAttachments(type = "*/*") {
|
||||
multiple: true
|
||||
});
|
||||
if (!files) return;
|
||||
return await attachFiles(files);
|
||||
return await attachFiles(files, type === "*/*");
|
||||
}
|
||||
|
||||
export async function attachFiles(files: File[]) {
|
||||
export async function attachFiles(
|
||||
files: File[],
|
||||
skipSpecialImageHandling = false
|
||||
) {
|
||||
let images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
|
||||
"imageCompression",
|
||||
@@ -87,9 +90,10 @@ export async function attachFiles(files: File[]) {
|
||||
const documents = files.filter((f) => !f.type.startsWith("image/"));
|
||||
const attachments: Attachment[] = [];
|
||||
for (const file of [...images, ...documents]) {
|
||||
const attachment = file.type.startsWith("image/")
|
||||
? await pickImage(file)
|
||||
: await pickFile(file);
|
||||
const attachment =
|
||||
!skipSpecialImageHandling && file.type.startsWith("image/")
|
||||
? await pickImage(file)
|
||||
: await pickFile(file);
|
||||
if (!attachment) continue;
|
||||
attachments.push(attachment);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ import { showFeatureNotAllowedToast } from "../../common/toasts";
|
||||
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
|
||||
import { ConfirmDialog } from "../../dialogs/confirm";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { AppEventManager, AppEvents } from "../../common/app-events";
|
||||
|
||||
export type OnChangeHandler = (
|
||||
content: () => string,
|
||||
@@ -631,7 +630,7 @@ function TiptapWrapper(
|
||||
sx={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
".tiptap.ProseMirror": { pb: 150 },
|
||||
".tiptap.ProseMirror": { height: "100%" },
|
||||
".editor-container": {
|
||||
opacity: isHydrating ? 0 : 1,
|
||||
zoom: editorConfig.zoom + "%",
|
||||
|
||||
@@ -676,7 +676,7 @@ function Colors({ noteId, color }: { noteId: string; color?: string }) {
|
||||
<Checkmark
|
||||
color="white"
|
||||
size={18}
|
||||
sx={{ position: "absolute", left: "8px" }}
|
||||
sx={{ position: "absolute", left: "4px" }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -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 { EV, EVENTS, hosts, MonographAnalytics } from "@notesnook/core";
|
||||
import { EVENTS, hosts } from "@notesnook/core";
|
||||
import { useStore } from "../../stores/monograph-store";
|
||||
import { Note } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
@@ -67,7 +67,7 @@ function PublishView(props: PublishViewProps) {
|
||||
}, [monograph?.id, monographAnalytics]);
|
||||
|
||||
useEffect(() => {
|
||||
const fileDownloadedEvent = EV.subscribe(
|
||||
const fileDownloadedEvent = db.eventManager.subscribe(
|
||||
EVENTS.fileDownloaded,
|
||||
({ total, current, groupId }) => {
|
||||
if (!groupId || !groupId.includes(note.id)) return;
|
||||
|
||||
@@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import NoteItem from "../components/note";
|
||||
import Dialog from "../components/dialog";
|
||||
import Field from "../components/field";
|
||||
import { Box, Button, Flex, Label, Radio, Text } from "@theme-ui/components";
|
||||
@@ -32,13 +33,18 @@ import { usePersistentState } from "../hooks/use-persistent-state";
|
||||
import { DayPicker } from "../components/day-picker";
|
||||
import { PopupPresenter } from "@notesnook/ui";
|
||||
import { useStore as useThemeStore } from "../stores/theme-store";
|
||||
import { getFormattedDate, useIsFeatureAvailable } from "@notesnook/common";
|
||||
import {
|
||||
getFormattedDate,
|
||||
useIsFeatureAvailable,
|
||||
usePromise
|
||||
} from "@notesnook/common";
|
||||
import { MONTHS_FULL, getTimeFormat } from "@notesnook/core";
|
||||
import { Note, Reminder } from "@notesnook/core";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { checkFeature } from "../common";
|
||||
import { setTimeOnly, setDateOnly } from "../utils/date-time";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
@@ -148,6 +154,15 @@ export const AddReminderDialog = DialogManager.register(
|
||||
const theme = useThemeStore((store) => store.colorScheme);
|
||||
const dateInputRef = useRef<HTMLInputElement>(null);
|
||||
const repeatModeAvailability = useIsFeatureAvailable("recurringReminders");
|
||||
const referencedNotes = usePromise(
|
||||
() =>
|
||||
reminder?.id
|
||||
? db.relations
|
||||
.to({ id: reminder.id, type: "reminder" }, "note")
|
||||
.resolve()
|
||||
: null,
|
||||
[reminder?.id]
|
||||
);
|
||||
|
||||
const repeatsDaily =
|
||||
(selectedDays.length === 7 && recurringMode === RecurringModes.WEEK) ||
|
||||
@@ -512,6 +527,32 @@ export const AddReminderDialog = DialogManager.register(
|
||||
{strings.reminderStarts(date.format(db.settings.getDateFormat()), date.format(timeFormat()))}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{reminder ? (
|
||||
referencedNotes && referencedNotes.status === "fulfilled" ? (
|
||||
referencedNotes.value !== null &&
|
||||
referencedNotes.value.length > 0 && (
|
||||
<Flex
|
||||
data-test-id="reminder-note-references"
|
||||
sx={{ my: 2, gap: 1, flexDirection: "column" }}
|
||||
>
|
||||
<Text variant="body">
|
||||
{strings.note()} {strings.references()}:
|
||||
</Text>
|
||||
{referencedNotes.value.map((item) => (
|
||||
<NoteItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
date={item.dateCreated}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
) : (
|
||||
<Skeleton count={1} />
|
||||
)
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ type RecoveryKeyDialogProps = BaseDialogProps<false>;
|
||||
export const RecoveryKeyDialog = DialogManager.register(
|
||||
function RecoveryKeyDialog(props: RecoveryKeyDialogProps) {
|
||||
const key = usePromise(() =>
|
||||
db.user.getEncryptionKey().then((key) => key?.key)
|
||||
db.user.getMasterKey().then((key) => key?.key)
|
||||
);
|
||||
const [copyText, setCopyText] = useState("Copy to clipboard");
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@ export const ReminderPreviewDialog = DialogManager.register(
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
my: 1,
|
||||
gap: 1
|
||||
gap: 1,
|
||||
flexWrap: "wrap"
|
||||
}}
|
||||
>
|
||||
{SNOOZE_TIMES.map((time) => (
|
||||
|
||||
@@ -27,7 +27,6 @@ import { RecoveryCodesDialog } from "../mfa/recovery-code-dialog";
|
||||
import { MultifactorDialog } from "../mfa/multi-factor-dialog";
|
||||
import { RecoveryKeyDialog } from "../recovery-key-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { ConfirmDialog } from "../confirm";
|
||||
|
||||
export const AuthenticationSettings: SettingsGroup[] = [
|
||||
{
|
||||
@@ -46,39 +45,38 @@ export const AuthenticationSettings: SettingsGroup[] = [
|
||||
title: strings.changePassword(),
|
||||
variant: "secondary",
|
||||
action: async () => {
|
||||
ConfirmDialog.show({
|
||||
title: "Password changing has been disabled temporarily",
|
||||
message:
|
||||
"Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved.",
|
||||
positiveButtonText: "Ok"
|
||||
const result = await showPasswordDialog({
|
||||
title: strings.changePassword(),
|
||||
message: strings.changePasswordDesc(),
|
||||
inputs: {
|
||||
oldPassword: {
|
||||
label: strings.oldPassword(),
|
||||
autoComplete: "current-password"
|
||||
},
|
||||
newPassword: {
|
||||
label: strings.newPassword(),
|
||||
autoComplete: "new-password"
|
||||
}
|
||||
},
|
||||
validate: async ({ oldPassword, newPassword }) => {
|
||||
try {
|
||||
if (!(await createBackup({ noVerify: true }))) return false;
|
||||
return (
|
||||
(await db.user.changePassword(
|
||||
oldPassword,
|
||||
newPassword
|
||||
)) || false
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
// const result = await showPasswordDialog({
|
||||
// title: strings.changePassword(),
|
||||
// message: strings.changePasswordDesc(),
|
||||
// inputs: {
|
||||
// oldPassword: {
|
||||
// label: strings.oldPassword(),
|
||||
// autoComplete: "current-password"
|
||||
// },
|
||||
// newPassword: {
|
||||
// label: strings.newPassword(),
|
||||
// autoComplete: "new-password"
|
||||
// }
|
||||
// },
|
||||
// validate: async ({ oldPassword, newPassword }) => {
|
||||
// if (!(await createBackup())) return false;
|
||||
// await db.user.clearSessions();
|
||||
// return (
|
||||
// (await db.user.changePassword(oldPassword, newPassword)) ||
|
||||
// false
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
// if (result) {
|
||||
// showToast("success", strings.passwordChangedSuccessfully());
|
||||
// await RecoveryKeyDialog.show({});
|
||||
// }
|
||||
if (result) {
|
||||
showToast("success", strings.passwordChangedSuccessfully());
|
||||
await RecoveryKeyDialog.show({});
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -421,19 +421,19 @@ type BaseTreeNode<Type extends TreeNodeType> = {
|
||||
depth: number;
|
||||
};
|
||||
|
||||
type Subgroup = BaseTreeNode<"group"> & {
|
||||
export type Subgroup = BaseTreeNode<"group"> & {
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
type Group = BaseTreeNode<"group">;
|
||||
export type Group = BaseTreeNode<"group">;
|
||||
|
||||
type Item = BaseTreeNode<"item"> & {
|
||||
export type Item = BaseTreeNode<"item"> & {
|
||||
toolId: ToolId;
|
||||
icon: string;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
type TreeNode = Group | Item | Subgroup;
|
||||
export type TreeNode = Group | Item | Subgroup;
|
||||
|
||||
function flatten(tools: ToolbarGroupDefinition[], depth = 0): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
@@ -544,7 +544,11 @@ function canMoveGroup(
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveItem(items: TreeNode[], fromId: string, toId: string): TreeNode[] {
|
||||
export function moveItem(
|
||||
items: TreeNode[],
|
||||
fromId: string,
|
||||
toId: string
|
||||
): TreeNode[] {
|
||||
const fromIndex = items.findIndex((i) => i.id === fromId);
|
||||
const toIndex = items.findIndex((i) => i.id === toId);
|
||||
|
||||
@@ -555,13 +559,14 @@ function moveItem(items: TreeNode[], fromId: string, toId: string): TreeNode[] {
|
||||
|
||||
const movingToGroup = isGroup(toItem) || isSubgroup(toItem);
|
||||
|
||||
// we need to adjust the item depth according to where the item
|
||||
// is going to be moved.
|
||||
if (fromItem.depth !== toItem.depth) fromItem.depth = toItem.depth;
|
||||
|
||||
// if we are moving to the start of the group, we need to adjust the
|
||||
// depth accordingly.
|
||||
if (movingToGroup) fromItem.depth = toItem.depth + 1;
|
||||
// calculate the correct depth based on where the item is being moved
|
||||
if (movingToGroup) {
|
||||
// if moving to a group/subgroup, set depth to group's depth + 1
|
||||
fromItem.depth = toItem.depth + 1;
|
||||
} else {
|
||||
// if moving to another item, set depth to match the target item's depth
|
||||
fromItem.depth = toItem.depth;
|
||||
}
|
||||
|
||||
const newArray = arrayMove(items, fromIndex, toIndex);
|
||||
|
||||
|
||||
@@ -581,6 +581,7 @@ function SettingItem(props: { item: Setting }) {
|
||||
case "input":
|
||||
return component.inputType === "number" ? (
|
||||
<Input
|
||||
key={component.defaultValue()}
|
||||
type={"number"}
|
||||
min={component.min}
|
||||
max={component.max}
|
||||
@@ -597,6 +598,16 @@ function SettingItem(props: { item: Setting }) {
|
||||
: value;
|
||||
component.onChange(value);
|
||||
}, 500)}
|
||||
onBlur={(e) => {
|
||||
let value = e.target.valueAsNumber;
|
||||
value =
|
||||
Number.isNaN(value) || value < component.min
|
||||
? component.min
|
||||
: value > component.max
|
||||
? component.max
|
||||
: value;
|
||||
component.onChange(value);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -607,6 +618,7 @@ function SettingItem(props: { item: Setting }) {
|
||||
(e) => component.onChange(e.target.value),
|
||||
500
|
||||
)}
|
||||
onBlur={(e) => component.onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
case "icon":
|
||||
|
||||
@@ -35,6 +35,7 @@ import { EmailChangeDialog } from "../email-change-dialog";
|
||||
import { RecoveryKeyDialog } from "../recovery-key-dialog";
|
||||
import { UserProfile } from "./components/user-profile";
|
||||
import { SettingsGroup } from "./types";
|
||||
import Config from "../../utils/config";
|
||||
|
||||
export const ProfileSettings: SettingsGroup[] = [
|
||||
{
|
||||
@@ -90,6 +91,37 @@ export const ProfileSettings: SettingsGroup[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "delete-data-for-not-logged-in-user",
|
||||
title: strings.deleteData(),
|
||||
description: strings.deleteAccountDesc(),
|
||||
keywords: [
|
||||
strings.deleteData(),
|
||||
strings.deleteAccount(),
|
||||
strings.clear()
|
||||
],
|
||||
isHidden: () => Boolean(useUserStore.getState().isLoggedIn),
|
||||
components: [
|
||||
{
|
||||
type: "button",
|
||||
variant: "error",
|
||||
title: strings.deleteData(),
|
||||
action: async () => {
|
||||
const ok = await ConfirmDialog.show({
|
||||
title: strings.deleteData(),
|
||||
message: strings.deleteAccountDesc(),
|
||||
positiveButtonText: strings.yes(),
|
||||
negativeButtonText: strings.no()
|
||||
});
|
||||
if (ok) {
|
||||
Config.clear();
|
||||
await db.reset();
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "account-removal",
|
||||
title: strings.deleteAccount(),
|
||||
|
||||
@@ -41,10 +41,8 @@ export const VaultSettings: SettingsGroup[] = [
|
||||
{
|
||||
type: "button",
|
||||
title: strings.create(),
|
||||
action: () => {
|
||||
Vault.createVault().then((res) => {
|
||||
useAppStore.getState().setIsVaultCreated(res);
|
||||
});
|
||||
action: async () => {
|
||||
await Vault.createVault();
|
||||
},
|
||||
variant: "secondary"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EV, EVENTS } from "@notesnook/core";
|
||||
import { EVENTS } from "@notesnook/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import Vault from "../common/vault";
|
||||
import { db } from "../common/db";
|
||||
@@ -7,8 +7,8 @@ export function useVault() {
|
||||
const [isLocked, setIsLocked] = useState(!db.vault.unlocked);
|
||||
|
||||
useEffect(() => {
|
||||
EV.subscribe(EVENTS.vaultLocked, () => setIsLocked(true));
|
||||
EV.subscribe(EVENTS.vaultUnlocked, () => setIsLocked(false));
|
||||
db.eventManager.subscribe(EVENTS.vaultLocked, () => setIsLocked(true));
|
||||
db.eventManager.subscribe(EVENTS.vaultUnlocked, () => setIsLocked(false));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -667,6 +667,28 @@ async function deleteFile(
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeleteFiles(
|
||||
filenames: string[],
|
||||
requestOptions?: RequestOptionsWithSignal
|
||||
) {
|
||||
if (!requestOptions) {
|
||||
await streamablefs.bulkDeleteFiles(filenames);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, headers } = requestOptions;
|
||||
const response = await axios.post(url, { names: filenames }, { headers });
|
||||
|
||||
const result = isSuccessStatusCode(response.status);
|
||||
if (result) await streamablefs.bulkDeleteFiles(filenames);
|
||||
return result;
|
||||
} catch (e) {
|
||||
showError(toS3Error(e), "Could not bulk delete files");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `-1` means an error during file size
|
||||
*
|
||||
@@ -701,6 +723,7 @@ export const FileStorage: IFileStorage = {
|
||||
uploadFile: cancellable(uploadFile),
|
||||
downloadFile: cancellable(downloadFile),
|
||||
deleteFile,
|
||||
bulkDeleteFiles,
|
||||
exists,
|
||||
clearFileStorage,
|
||||
hashBase64,
|
||||
|
||||
@@ -146,13 +146,19 @@ class KeyStore extends BaseStore<KeyStore> {
|
||||
|
||||
activeCredentials = () => this.get().credentials.filter((c) => c.active);
|
||||
|
||||
init = async () => {
|
||||
init = async (
|
||||
config: { persistence: "memory" | "db" } = { persistence: "db" }
|
||||
) => {
|
||||
this.#metadataStore =
|
||||
isFeatureSupported("indexedDB") && isFeatureSupported("clonableCryptoKey")
|
||||
isFeatureSupported("indexedDB") &&
|
||||
isFeatureSupported("clonableCryptoKey") &&
|
||||
config.persistence !== "memory"
|
||||
? new IndexedDBKVStore(`${this.dbName}-metadata`, "metadata")
|
||||
: new MemoryKVStore();
|
||||
this.#secretStore =
|
||||
isFeatureSupported("indexedDB") && isFeatureSupported("clonableCryptoKey")
|
||||
isFeatureSupported("indexedDB") &&
|
||||
isFeatureSupported("clonableCryptoKey") &&
|
||||
config.persistence !== "memory"
|
||||
? new IndexedDBKVStore(`${this.dbName}-secrets`, "secrets")
|
||||
: new MemoryKVStore();
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from "./components/error-boundary";
|
||||
import { desktop } from "./common/desktop-bridge";
|
||||
import { useKeyStore } from "./interfaces/key-store";
|
||||
import Config from "./utils/config";
|
||||
import { usePromise } from "@notesnook/common";
|
||||
import { AuthProps } from "./views/auth";
|
||||
import { loadDatabase } from "./hooks/use-database";
|
||||
@@ -52,9 +51,9 @@ export async function startApp(children?: React.ReactNode) {
|
||||
: await import("./components/title-bar").then((m) => m.TitleBar);
|
||||
|
||||
try {
|
||||
const { Component, props, path } = await init();
|
||||
const { Component, props, path, persistence } = await init();
|
||||
|
||||
await useKeyStore.getState().init();
|
||||
await useKeyStore.getState().init({ persistence });
|
||||
|
||||
root.render(
|
||||
<>
|
||||
@@ -70,6 +69,7 @@ export async function startApp(children?: React.ReactNode) {
|
||||
Component={Component}
|
||||
path={path}
|
||||
routeProps={props}
|
||||
persistence={persistence}
|
||||
/>
|
||||
</AppLock>
|
||||
{children}
|
||||
@@ -96,28 +96,22 @@ function RouteWrapper(props: {
|
||||
Component: (props: AuthProps) => JSX.Element;
|
||||
path: Routes;
|
||||
routeProps: AuthProps | null;
|
||||
persistence?: "db" | "memory";
|
||||
}) {
|
||||
const [isMigrating, setIsMigrating] = useState(false);
|
||||
const { Component, path, routeProps } = props;
|
||||
const { Component, path, routeProps, persistence } = props;
|
||||
|
||||
useEffect(() => {
|
||||
EV.subscribe(EVENTS.migrationStarted, (name) =>
|
||||
setIsMigrating(name === "notesnook")
|
||||
);
|
||||
EV.subscribe(EVENTS.migrationFinished, () => setIsMigrating(false));
|
||||
return () => {
|
||||
EV.unsubscribeAll();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const result = usePromise(async () => {
|
||||
performance.mark("load:database");
|
||||
await loadDatabase(
|
||||
path !== "/sessionexpired" || Config.get("sessionExpired", false)
|
||||
? "db"
|
||||
: "memory"
|
||||
);
|
||||
}, [path]);
|
||||
await loadDatabase(persistence);
|
||||
}, [path, persistence]);
|
||||
|
||||
if (result.status === "rejected") {
|
||||
throw result.reason instanceof Error
|
||||
|
||||
@@ -30,7 +30,7 @@ import { store as settingStore } from "./setting-store";
|
||||
import BaseStore from "./index";
|
||||
import { showToast } from "../utils/toast";
|
||||
import { Notice } from "../common/notices";
|
||||
import { EV, EVENTS, SYNC_CHECK_IDS, SyncOptions } from "@notesnook/core";
|
||||
import { EVENTS, SYNC_CHECK_IDS, SyncOptions } from "@notesnook/core";
|
||||
import { logger } from "../utils/logger";
|
||||
import Config from "../utils/config";
|
||||
import {
|
||||
@@ -92,7 +92,7 @@ class AppStore extends BaseStore<AppStore> {
|
||||
});
|
||||
this.get().sync({ type: "full" });
|
||||
|
||||
EV.subscribe(EVENTS.appRefreshRequested, () => this.refresh());
|
||||
db.eventManager.subscribe(EVENTS.appRefreshRequested, () => this.refresh());
|
||||
db.eventManager.subscribe(EVENTS.syncCompleted, () => this.refresh());
|
||||
|
||||
db.eventManager.subscribe(EVENTS.syncProgress, ({ type, current }) => {
|
||||
@@ -105,7 +105,7 @@ class AppStore extends BaseStore<AppStore> {
|
||||
});
|
||||
});
|
||||
|
||||
EV.subscribe(EVENTS.syncCheckStatus, async (type) => {
|
||||
db.eventManager.subscribe(EVENTS.syncCheckStatus, async (type) => {
|
||||
const { isAutoSyncEnabled, isSyncEnabled } = this.get();
|
||||
switch (type) {
|
||||
case SYNC_CHECK_IDS.sync:
|
||||
|
||||
@@ -23,7 +23,7 @@ import { store as appStore } from "./app-store";
|
||||
import { useStore as useSettingStore } from "./setting-store";
|
||||
import { db } from "../common/db";
|
||||
import BaseStore from ".";
|
||||
import { EV, EVENTS } from "@notesnook/core";
|
||||
import { EVENTS } from "@notesnook/core";
|
||||
import { logger } from "../utils/logger";
|
||||
import Config from "../utils/config";
|
||||
import { setDocumentTitle } from "../utils/dom";
|
||||
@@ -255,7 +255,7 @@ class EditorStore extends BaseStore<EditorStore> {
|
||||
closeTabs(...tabs.map((s) => s.id));
|
||||
});
|
||||
|
||||
EV.subscribe(EVENTS.vaultLocked, () => {
|
||||
db.eventManager.subscribe(EVENTS.vaultLocked, () => {
|
||||
this.set((state) => {
|
||||
state.sessions = state.sessions.map((session) => {
|
||||
if (isLockedSession(session)) {
|
||||
|
||||
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import createStore from "../common/store";
|
||||
import { db } from "../common/db";
|
||||
import BaseStore from "./index";
|
||||
import { EV, EVENTS } from "@notesnook/core";
|
||||
import { EVENTS } from "@notesnook/core";
|
||||
import Config from "../utils/config";
|
||||
import { hashNavigate } from "../navigation";
|
||||
import { AuthenticatorType, User } from "@notesnook/core";
|
||||
@@ -39,7 +39,7 @@ class UserStore extends BaseStore<UserStore> {
|
||||
counter = 0;
|
||||
|
||||
init = () => {
|
||||
EV.subscribe(EVENTS.userSessionExpired, async () => {
|
||||
db.eventManager.subscribe(EVENTS.userSessionExpired, async () => {
|
||||
Config.set("sessionExpired", true);
|
||||
window.location.replace("/sessionexpired");
|
||||
});
|
||||
@@ -53,7 +53,8 @@ class UserStore extends BaseStore<UserStore> {
|
||||
user,
|
||||
isLoggedIn: true
|
||||
});
|
||||
if (Config.get("sessionExpired")) EV.publish(EVENTS.userSessionExpired);
|
||||
if (Config.get("sessionExpired"))
|
||||
db.eventManager.publish(EVENTS.userSessionExpired);
|
||||
});
|
||||
|
||||
if (Config.get("sessionExpired")) return;
|
||||
|
||||
26
apps/web/src/utils/error.ts
Normal file
26
apps/web/src/utils/error.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
export function rewriteError(e: Error, message: string) {
|
||||
const error = new Error(message);
|
||||
error.stack = e.stack;
|
||||
error.name = e.name;
|
||||
error.cause = e.cause;
|
||||
return error;
|
||||
}
|
||||
@@ -34,11 +34,11 @@ export function isTransferableStreamsSupported() {
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
window.postMessage(readable, [readable]);
|
||||
window.postMessage(readable, window.location.origin, [readable]);
|
||||
FEATURE_CHECKS.transferableStreams = true;
|
||||
return true;
|
||||
} catch {
|
||||
console.log("Transferable streams not supported");
|
||||
} catch (e) {
|
||||
console.log("Transferable streams not supported", e);
|
||||
FEATURE_CHECKS.transferableStreams = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import AuthContainer from "../components/auth-container";
|
||||
import { useTimer } from "../hooks/use-timer";
|
||||
import { ErrorText } from "../components/error-text";
|
||||
import { AuthenticatorType, User } from "@notesnook/core";
|
||||
import { ConfirmDialog, showLogoutConfirmation } from "../dialogs/confirm";
|
||||
import { showLogoutConfirmation } from "../dialogs/confirm";
|
||||
import { TaskManager } from "../common/task-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { ScrollContainer } from "@notesnook/ui";
|
||||
@@ -339,16 +339,7 @@ function LoginPassword(props: BaseAuthComponentProps<"login:password">) {
|
||||
type="button"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
onClick={() => {
|
||||
ConfirmDialog.show({
|
||||
title: "Password changing has been disabled temporarily",
|
||||
message:
|
||||
"Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved.",
|
||||
positiveButtonText: "Ok"
|
||||
});
|
||||
return;
|
||||
// navigate("recover", { email: formData.email })
|
||||
}}
|
||||
onClick={() => navigate("recover", { email: formData.email })}
|
||||
sx={{ color: "paragraph", alignSelf: "end" }}
|
||||
>
|
||||
{strings.forgotPassword()}
|
||||
@@ -508,16 +499,7 @@ function SessionExpiry(props: BaseAuthComponentProps<"sessionExpiry">) {
|
||||
type="button"
|
||||
mt={2}
|
||||
variant="anchor"
|
||||
onClick={() => {
|
||||
ConfirmDialog.show({
|
||||
title: "Password changing has been disabled temporarily",
|
||||
message:
|
||||
"Password changing has been disabled temporarily to address some issues faced by users. It will be enabled again once the issues have resolved.",
|
||||
positiveButtonText: "Ok"
|
||||
});
|
||||
return;
|
||||
// user && navigate("recover", { email: user.email })
|
||||
}}
|
||||
onClick={() => user && navigate("recover", { email: user.email })}
|
||||
sx={{ color: "paragraph", alignSelf: "end" }}
|
||||
>
|
||||
{strings.forgotPassword()}
|
||||
|
||||
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import "../app.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Button, Flex, Text } from "@theme-ui/components";
|
||||
import { hardNavigate, hashNavigate, useQueryParams } from "../navigation";
|
||||
import { hardNavigate, useQueryParams } from "../navigation";
|
||||
import { Support } from "../components/icons";
|
||||
import { HeadlessAuth } from "./auth";
|
||||
import {
|
||||
@@ -39,7 +39,8 @@ import { isUserSubscribed } from "../hooks/use-is-user-premium";
|
||||
import { PLAN_METADATA } from "../dialogs/buy-dialog/plans";
|
||||
import { planToAvailability } from "@notesnook/common";
|
||||
import { FeatureCaption } from "../dialogs/buy-dialog/feature-caption";
|
||||
import { EV, EVENTS } from "@notesnook/core";
|
||||
import { EVENTS } from "@notesnook/core";
|
||||
import { db } from "../common/db";
|
||||
|
||||
export type Plan = z.infer<typeof PlanSchema>;
|
||||
|
||||
@@ -119,9 +120,12 @@ function Checkout() {
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 2) {
|
||||
const event = EV.subscribe(EVENTS.userSubscriptionUpdated, () => {
|
||||
hardNavigate("/notes#/welcome");
|
||||
});
|
||||
const event = db.eventManager.subscribe(
|
||||
EVENTS.userSubscriptionUpdated,
|
||||
() => {
|
||||
hardNavigate("/notes#/welcome");
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
event.unsubscribe();
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ import { Loader } from "../components/loader";
|
||||
import { showToast } from "../utils/toast";
|
||||
import AuthContainer from "../components/auth-container";
|
||||
import { AuthField, SubmitButton } from "./auth";
|
||||
import { createBackup, restoreBackupFile, selectBackupFile } from "../common";
|
||||
import Config from "../utils/config";
|
||||
import { ErrorText } from "../components/error-text";
|
||||
import { EVENTS, User } from "@notesnook/core";
|
||||
@@ -33,18 +32,14 @@ import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useKeyStore } from "../interfaces/key-store";
|
||||
|
||||
type RecoveryMethodType = "key" | "backup" | "reset";
|
||||
type RecoveryMethodType = "key" | "reset";
|
||||
type RecoveryMethodsFormData = Record<string, unknown>;
|
||||
|
||||
type RecoveryKeyFormData = {
|
||||
recoveryKey: string;
|
||||
};
|
||||
|
||||
type BackupFileFormData = {
|
||||
backupFile: File;
|
||||
};
|
||||
|
||||
type NewPasswordFormData = BackupFileFormData & {
|
||||
type NewPasswordFormData = {
|
||||
userResetRequired?: boolean;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
@@ -53,9 +48,7 @@ type NewPasswordFormData = BackupFileFormData & {
|
||||
type RecoveryFormData = {
|
||||
methods: RecoveryMethodsFormData;
|
||||
"method:key": RecoveryKeyFormData;
|
||||
"method:backup": BackupFileFormData;
|
||||
"method:reset": NewPasswordFormData;
|
||||
backup: RecoveryMethodsFormData;
|
||||
new: NewPasswordFormData;
|
||||
final: RecoveryMethodsFormData;
|
||||
};
|
||||
@@ -73,9 +66,7 @@ type BaseRecoveryComponentProps<TRoute extends RecoveryRoutes> = {
|
||||
type RecoveryRoutes =
|
||||
| "methods"
|
||||
| "method:key"
|
||||
| "method:backup"
|
||||
| "method:reset"
|
||||
| "backup"
|
||||
| "new"
|
||||
| "final";
|
||||
type RecoveryProps = { route: RecoveryRoutes };
|
||||
@@ -92,10 +83,6 @@ function getRouteComponent<TRoute extends RecoveryRoutes>(
|
||||
return RecoveryMethods as RecoveryComponent<TRoute>;
|
||||
case "method:key":
|
||||
return RecoveryKeyMethod as RecoveryComponent<TRoute>;
|
||||
case "method:backup":
|
||||
return BackupFileMethod as RecoveryComponent<TRoute>;
|
||||
case "backup":
|
||||
return BackupData as RecoveryComponent<TRoute>;
|
||||
case "method:reset":
|
||||
case "new":
|
||||
return NewPassword as RecoveryComponent<TRoute>;
|
||||
@@ -108,9 +95,7 @@ function getRouteComponent<TRoute extends RecoveryRoutes>(
|
||||
const routePaths: Record<RecoveryRoutes, string> = {
|
||||
methods: "/account/recovery/methods",
|
||||
"method:key": "/account/recovery/method/key",
|
||||
"method:backup": "/account/recovery/method/backup",
|
||||
"method:reset": "/account/recovery/method/reset",
|
||||
backup: "/account/recovery/backup",
|
||||
new: "/account/recovery/new",
|
||||
final: "/account/recovery/final"
|
||||
};
|
||||
@@ -138,6 +123,7 @@ function useAuthenticateUser({
|
||||
const user = await db.user.fetchUser();
|
||||
setUser(user);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast("error", strings.biometricsAuthFailed());
|
||||
openURL("/");
|
||||
} finally {
|
||||
@@ -240,12 +226,6 @@ const recoveryMethods: RecoveryMethod[] = [
|
||||
title: () => strings.recoveryKeyMethod(),
|
||||
description: () => strings.recoveryKeyMethodDesc()
|
||||
},
|
||||
{
|
||||
type: "backup",
|
||||
testId: "step-backup",
|
||||
title: () => strings.backupFileMethod(),
|
||||
description: () => strings.backupFileMethodDesc()
|
||||
},
|
||||
{
|
||||
type: "reset",
|
||||
testId: "step-reset-account",
|
||||
@@ -356,8 +336,7 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
await useKeyStore
|
||||
.getState()
|
||||
.setValue("userEncryptionKey", form.recoveryKey);
|
||||
await db.sync({ type: "fetch", force: true });
|
||||
navigate("backup");
|
||||
navigate("new", {});
|
||||
}}
|
||||
>
|
||||
<AuthField
|
||||
@@ -383,86 +362,6 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
);
|
||||
}
|
||||
|
||||
function BackupFileMethod(props: BaseRecoveryComponentProps<"method:backup">) {
|
||||
const { navigate } = props;
|
||||
const [backupFile, setBackupFile] =
|
||||
useState<BackupFileFormData["backupFile"]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!backupFile) return;
|
||||
const backupFileInput = document.getElementById("backupFile");
|
||||
if (!(backupFileInput instanceof HTMLInputElement)) return;
|
||||
backupFileInput.value = backupFile?.name;
|
||||
}, [backupFile]);
|
||||
|
||||
return (
|
||||
<RecoveryForm
|
||||
testId="step-backup-file"
|
||||
type="method:backup"
|
||||
title={strings.accountRecovery()}
|
||||
subtitle={
|
||||
<ErrorText
|
||||
sx={{ fontSize: "body" }}
|
||||
error={strings.backupFileRecoveryError()}
|
||||
/>
|
||||
}
|
||||
onSubmit={async () => {
|
||||
navigate("new", { backupFile, userResetRequired: true });
|
||||
}}
|
||||
>
|
||||
<AuthField
|
||||
id="backupFile"
|
||||
type="text"
|
||||
label={strings.selectBackupFile()}
|
||||
helpText={strings.backupFileHelpText()}
|
||||
autoComplete="none"
|
||||
autoFocus
|
||||
disabled
|
||||
action={{
|
||||
component: <Text variant={"body"}>{strings.browse()}</Text>,
|
||||
onClick: async () => {
|
||||
setBackupFile(await selectBackupFile());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SubmitButton text={strings.startAccountRecovery()} />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
mt={4}
|
||||
variant={"anchor"}
|
||||
onClick={() => navigate("methods")}
|
||||
sx={{ color: "paragraph" }}
|
||||
>
|
||||
{strings.dontHaveBackupFile()}
|
||||
</Button>
|
||||
</RecoveryForm>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupData(props: BaseRecoveryComponentProps<"backup">) {
|
||||
const { navigate } = props;
|
||||
|
||||
return (
|
||||
<RecoveryForm
|
||||
testId="step-backup-data"
|
||||
type="backup"
|
||||
title={strings.backupYourData()}
|
||||
subtitle={strings.backupYourDataDesc()}
|
||||
loading={{
|
||||
title: strings.backingUpData() + "...",
|
||||
subtitle: strings.backingUpDataWait()
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
await createBackup({ rescueMode: true, mode: "full" });
|
||||
navigate("new");
|
||||
}}
|
||||
>
|
||||
<SubmitButton text={strings.downloadBackupFile()} />
|
||||
</RecoveryForm>
|
||||
);
|
||||
}
|
||||
|
||||
function NewPassword(props: BaseRecoveryComponentProps<"new">) {
|
||||
const { navigate, formData } = props;
|
||||
const [progress, setProgress] = useState(0);
|
||||
@@ -498,11 +397,6 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
|
||||
if (!(await db.user.resetPassword(form.password)))
|
||||
throw new Error("Could not reset account password.");
|
||||
|
||||
if (formData?.backupFile) {
|
||||
await restoreBackupFile(formData?.backupFile);
|
||||
await db.sync({ type: "full", force: true });
|
||||
}
|
||||
|
||||
navigate("final");
|
||||
}}
|
||||
>
|
||||
|
||||
13
fastlane/metadata/android/en-US/changelogs/15459.txt
Normal file
13
fastlane/metadata/android/en-US/changelogs/15459.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
- Add notebooks, tags and colors to Home Screen Shortcuts
|
||||
- Change day format and use /day in notes
|
||||
- Add Setting to change default editor line height
|
||||
- Set a custom title for monographs
|
||||
- Add webpage title and date clipped to web clips
|
||||
- Configure Week to start from Sunday or Monday
|
||||
- Change Note's creation date
|
||||
- Set expiry date on notes
|
||||
- Temporarily disable password change and recovery options
|
||||
- Note history now includes note title
|
||||
- Minor bug fixes
|
||||
|
||||
Thank you for using Notesnook!
|
||||
3
fastlane/metadata/android/en-US/changelogs/15464.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/15464.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
- Bug fixes and minor improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
FeatureResult,
|
||||
isFeatureAvailable
|
||||
} from "../utils/index.js";
|
||||
import { EV, EVENTS } from "@notesnook/core";
|
||||
import { EVENTS } from "@notesnook/core";
|
||||
import { database } from "../database.js";
|
||||
|
||||
export function useIsFeatureAvailable<TId extends FeatureId>(
|
||||
id: TId | undefined,
|
||||
@@ -36,7 +37,7 @@ export function useIsFeatureAvailable<TId extends FeatureId>(
|
||||
if (!id) return;
|
||||
|
||||
isFeatureAvailable(id, value).then((result) => setResult(result));
|
||||
const userSubscriptionUpdated = EV.subscribe(
|
||||
const userSubscriptionUpdated = database.eventManager.subscribe(
|
||||
EVENTS.userSubscriptionUpdated,
|
||||
() => {
|
||||
isFeatureAvailable(id, value).then((result) => setResult(result));
|
||||
@@ -59,7 +60,7 @@ export function useAreFeaturesAvailable<TIds extends FeatureId[]>(
|
||||
|
||||
useEffect(() => {
|
||||
areFeaturesAvailable(ids, values).then((result) => setResult(result));
|
||||
const userSubscriptionUpdated = EV.subscribe(
|
||||
const userSubscriptionUpdated = database.eventManager.subscribe(
|
||||
EVENTS.userSubscriptionUpdated,
|
||||
() => {
|
||||
areFeaturesAvailable(ids, values).then((result) => setResult(result));
|
||||
|
||||
@@ -95,6 +95,16 @@ async function deleteFile(filename: string, _requestOptions: RequestOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bulkDeleteFiles(
|
||||
filenames: string[],
|
||||
_requestOptions: RequestOptions
|
||||
) {
|
||||
for (const filename of filenames) {
|
||||
await deleteFile(filename, _requestOptions);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function exists(filename) {
|
||||
return hasItem(filename);
|
||||
}
|
||||
@@ -109,6 +119,7 @@ export const FS: IFileStorage = {
|
||||
uploadFile: cancellable(uploadFile),
|
||||
downloadFile: cancellable(downloadFile),
|
||||
deleteFile,
|
||||
bulkDeleteFiles,
|
||||
exists,
|
||||
clearFileStorage,
|
||||
hashBase64
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SerializedKeyPair
|
||||
} from "@notesnook/crypto";
|
||||
import { IStorage } from "../src/interfaces.js";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
export class NodeStorageInterface implements IStorage {
|
||||
storage = {};
|
||||
@@ -112,7 +113,7 @@ export class NodeStorageInterface implements IStorage {
|
||||
password: string,
|
||||
salt?: string | undefined
|
||||
): Promise<SerializedKey> {
|
||||
return { password, salt };
|
||||
return { password, salt: salt || randomBytes(16).toString("base64") };
|
||||
}
|
||||
|
||||
generateCryptoKeyPair(): Promise<SerializedKeyPair> {
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
notebookTest,
|
||||
TEST_NOTE,
|
||||
TEST_NOTEBOOK,
|
||||
databaseTest
|
||||
databaseTest,
|
||||
loginFakeUser
|
||||
} from "./utils/index.js";
|
||||
import { test, expect } from "vitest";
|
||||
|
||||
@@ -384,3 +385,20 @@ test("permanently deleted note should not have note fields", () =>
|
||||
"synced"
|
||||
]);
|
||||
}));
|
||||
|
||||
test("trash cleanup should remove orphaned attachments", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
|
||||
const hash = await db.attachments.save(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"image/png",
|
||||
"test.png"
|
||||
);
|
||||
if (!hash) throw new Error("Failed to create attachment");
|
||||
|
||||
await db.trash.cleanup();
|
||||
|
||||
expect(await db.attachments.exists(hash)).toBe(false);
|
||||
expect(await db.attachments.orphaned.count()).toBe(0);
|
||||
}));
|
||||
|
||||
@@ -109,18 +109,17 @@ function delay(ms: number) {
|
||||
|
||||
async function loginFakeUser(db) {
|
||||
const email = "johndoe@example.com";
|
||||
const password = "password";
|
||||
const userSalt = randomBytes(16).toString("base64");
|
||||
await db.storage().deriveCryptoKey({
|
||||
password: "password",
|
||||
password,
|
||||
salt: userSalt
|
||||
});
|
||||
|
||||
const userEncryptionKey = await db.storage().getCryptoKey(`_uk_@${email}`);
|
||||
|
||||
const key = await db.crypto().generateRandomKey();
|
||||
const attachmentsKey = await db
|
||||
.storage()
|
||||
.encrypt({ password: userEncryptionKey }, JSON.stringify(key));
|
||||
.encrypt({ password, salt: userSalt }, JSON.stringify(key));
|
||||
|
||||
await db.user.setUser({
|
||||
email,
|
||||
|
||||
@@ -30,7 +30,7 @@ import Lookup from "./lookup.js";
|
||||
import { Content } from "../collections/content.js";
|
||||
import Backup from "../database/backup.js";
|
||||
import Hosts from "../utils/constants.js";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { EVENTS } from "../common.js";
|
||||
import { LegacySettings } from "../collections/legacy-settings.js";
|
||||
import Migrations from "./migrations.js";
|
||||
import UserManager from "./user-manager.js";
|
||||
@@ -126,7 +126,11 @@ class Database {
|
||||
);
|
||||
return (
|
||||
this._fs ||
|
||||
(this._fs = new FileStorage(this.options.fs, this.tokenManager))
|
||||
(this._fs = new FileStorage(
|
||||
this.options.fs,
|
||||
this.tokenManager,
|
||||
this.eventManager
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -191,7 +195,7 @@ class Database {
|
||||
options!: Options;
|
||||
eventSource?: EventSource | null;
|
||||
|
||||
tokenManager = new TokenManager(this.kv);
|
||||
tokenManager = new TokenManager(this.kv, this.eventManager);
|
||||
mfa = new MFAManager(this.tokenManager);
|
||||
subscriptions = new Subscriptions(this);
|
||||
circle = new Circle(this);
|
||||
@@ -293,10 +297,13 @@ class Database {
|
||||
this.connectSSE,
|
||||
this
|
||||
);
|
||||
EV.subscribe(EVENTS.tokenRefreshed, () => this.connectSSE());
|
||||
EV.subscribe(EVENTS.attachmentDeleted, async (attachment: Attachment) => {
|
||||
await this.fs().cancel(attachment.hash);
|
||||
});
|
||||
this.eventManager.subscribe(EVENTS.tokenRefreshed, () => this.connectSSE());
|
||||
this.eventManager.subscribe(
|
||||
EVENTS.attachmentDeleted,
|
||||
async (attachment: Attachment) => {
|
||||
await this.fs().cancel(attachment.hash);
|
||||
}
|
||||
);
|
||||
this.eventManager.subscribe(EVENTS.userLoggedOut, async () => {
|
||||
await this.monographs.clear();
|
||||
await this.fs().clear();
|
||||
|
||||
145
packages/core/src/api/key-manager.ts
Normal file
145
packages/core/src/api/key-manager.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
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 { Cipher, SerializedKey, SerializedKeyPair } from "@notesnook/crypto";
|
||||
import Database from ".";
|
||||
import { isCipher } from "../utils/index.js";
|
||||
|
||||
const KEY_INFO = {
|
||||
inboxKeys: {
|
||||
type: "asymmetric"
|
||||
},
|
||||
attachmentsKey: {
|
||||
type: "symmetric"
|
||||
},
|
||||
monographPasswordsKey: {
|
||||
type: "symmetric"
|
||||
},
|
||||
dataEncryptionKey: {
|
||||
type: "symmetric"
|
||||
},
|
||||
legacyDataEncryptionKey: {
|
||||
type: "symmetric"
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type KeyId = keyof typeof KEY_INFO;
|
||||
|
||||
type WrapKeyReturnType<T extends SerializedKeyPair | SerializedKey> =
|
||||
T extends SerializedKeyPair
|
||||
? { public: string; private: Cipher<"base64"> }
|
||||
: Cipher<"base64">;
|
||||
|
||||
type WrappedKey =
|
||||
| Cipher<"base64">
|
||||
| {
|
||||
public: string;
|
||||
private: Cipher<"base64">;
|
||||
};
|
||||
|
||||
export type UnwrapKeyReturnType<T extends WrappedKey> = T extends {
|
||||
public: string;
|
||||
private: Cipher<"base64">;
|
||||
}
|
||||
? SerializedKeyPair
|
||||
: SerializedKey;
|
||||
|
||||
export type KeyTypeFromId<TId extends KeyId> =
|
||||
(typeof KEY_INFO)[TId]["type"] extends "symmetric"
|
||||
? Cipher<"base64">
|
||||
: {
|
||||
public: string;
|
||||
private: Cipher<"base64">;
|
||||
};
|
||||
|
||||
export class KeyManager {
|
||||
private cache = new Map<string, KeyTypeFromId<KeyId>>();
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
async get<TId extends KeyId>(
|
||||
id: TId,
|
||||
options: {
|
||||
useCache?: boolean;
|
||||
refetchUser?: boolean;
|
||||
} = { refetchUser: true, useCache: true }
|
||||
): Promise<KeyTypeFromId<TId> | undefined> {
|
||||
if (options.useCache && this.cache.has(id)) {
|
||||
return this.cache.get(id) as KeyTypeFromId<TId>;
|
||||
}
|
||||
let user = await this.db.user.getUser();
|
||||
if ((!user || !user[id]) && options.refetchUser) {
|
||||
user = await this.db.user.fetchUser();
|
||||
}
|
||||
if (!user) return;
|
||||
|
||||
this.cache.set(id, user[id] as KeyTypeFromId<KeyId>);
|
||||
return user[id] as KeyTypeFromId<TId>;
|
||||
}
|
||||
|
||||
async unwrapKey<T extends WrappedKey>(
|
||||
key: T,
|
||||
wrappingKey: SerializedKey
|
||||
): Promise<UnwrapKeyReturnType<T>> {
|
||||
if (isCipher(key))
|
||||
return JSON.parse(
|
||||
await this.db.storage().decrypt(wrappingKey, key)
|
||||
) as UnwrapKeyReturnType<T>;
|
||||
else {
|
||||
const privateKey = await this.db
|
||||
.storage()
|
||||
.decrypt(wrappingKey, key.private);
|
||||
return {
|
||||
publicKey: key.public,
|
||||
privateKey
|
||||
} as UnwrapKeyReturnType<T>;
|
||||
}
|
||||
}
|
||||
|
||||
async wrapKey<T extends SerializedKey | SerializedKeyPair>(
|
||||
key: T,
|
||||
wrappingKey: SerializedKey
|
||||
): Promise<WrapKeyReturnType<T>> {
|
||||
if (!("publicKey" in key)) {
|
||||
return (await this.db
|
||||
.storage()
|
||||
.encrypt(wrappingKey, JSON.stringify(key))) as WrapKeyReturnType<T>;
|
||||
} else {
|
||||
const encryptedPrivateKey = await this.db
|
||||
.storage()
|
||||
.encrypt(wrappingKey, (key as SerializedKeyPair).privateKey);
|
||||
return {
|
||||
public: (key as SerializedKeyPair).publicKey,
|
||||
private: encryptedPrivateKey
|
||||
} as WrapKeyReturnType<T>;
|
||||
}
|
||||
}
|
||||
|
||||
async rewrapKey<T extends WrappedKey>(
|
||||
key: T,
|
||||
oldWrappingKey: SerializedKey,
|
||||
newWrappingKey: SerializedKey
|
||||
) {
|
||||
const unwrappedKey = await this.unwrapKey(key, oldWrappingKey);
|
||||
return await this.wrapKey(unwrappedKey, newWrappingKey);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,223 @@ test("unlinked relation should get included in collector", () =>
|
||||
expect(items[0].items[0].id).toBe("cd93df7a4c64fbd5f100361d629ac5b5");
|
||||
}));
|
||||
|
||||
test("collector should use latest key version for encryption", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const collector = new Collector(db);
|
||||
|
||||
const noteId = await db.notes.add(TEST_NOTE);
|
||||
|
||||
const items = await iteratorToArray(collector.collect(100, false));
|
||||
|
||||
// Find the note item
|
||||
const noteItem = items.find((i) => i.type === "note");
|
||||
expect(noteItem).toBeDefined();
|
||||
expect(noteItem.items[0].keyVersion).toBeDefined();
|
||||
|
||||
// Should use the latest key version available
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
const latestKeyVersion = Math.max(...keys.map((k) => k.version));
|
||||
expect(noteItem.items[0].keyVersion).toBe(latestKeyVersion);
|
||||
}));
|
||||
|
||||
test("collector should assign keyVersion to all encrypted items", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const collector = new Collector(db);
|
||||
|
||||
await db.notes.add(TEST_NOTE);
|
||||
await db.notes.add({ ...TEST_NOTE, title: "Note 2" });
|
||||
await db.notes.add({ ...TEST_NOTE, title: "Note 3" });
|
||||
|
||||
const items = await iteratorToArray(collector.collect(100, false));
|
||||
|
||||
// All items should have keyVersion set
|
||||
for (const chunk of items) {
|
||||
for (const item of chunk.items) {
|
||||
expect(item.keyVersion).toBeDefined();
|
||||
expect(typeof item.keyVersion).toBe("number");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
test("sync roundtrip: items encrypted with keyVersion can be decrypted", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const { Sync } = await import("../index.ts");
|
||||
const sync = new Sync(db);
|
||||
const collector = new Collector(db);
|
||||
|
||||
const noteId = await db.notes.add({
|
||||
...TEST_NOTE,
|
||||
title: "Sync Test Note"
|
||||
});
|
||||
const note = await db.notes.note(noteId);
|
||||
|
||||
const items = await iteratorToArray(collector.collect(100, false));
|
||||
const noteChunk = items.find((i) => i.type === "note");
|
||||
|
||||
expect(noteChunk).toBeDefined();
|
||||
expect(noteChunk.items[0].keyVersion).toBeDefined();
|
||||
|
||||
// Simulate receiving the same item back from server
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
await sync.processChunk(noteChunk, keys, { type: "fetch" });
|
||||
|
||||
// Verify the note is still intact
|
||||
const syncedNote = await db.notes.note(noteId);
|
||||
expect(syncedNote.title).toBe("Sync Test Note");
|
||||
expect(syncedNote.id).toBe(note.id);
|
||||
}));
|
||||
|
||||
test("sync should handle mixed keyVersion items in same chunk", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const { Sync } = await import("../index.ts");
|
||||
const sync = new Sync(db);
|
||||
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
|
||||
// Create mock items with different key versions
|
||||
const note1 = JSON.stringify({
|
||||
id: "note1",
|
||||
type: "note",
|
||||
title: "Note 1",
|
||||
dateModified: Date.now()
|
||||
});
|
||||
const note2 = JSON.stringify({
|
||||
id: "note2",
|
||||
type: "note",
|
||||
title: "Note 2",
|
||||
dateModified: Date.now()
|
||||
});
|
||||
|
||||
const cipher1 = await db.storage().encrypt(keys[0].key, note1);
|
||||
const cipher2 =
|
||||
keys.length > 1
|
||||
? await db.storage().encrypt(keys[1].key, note2)
|
||||
: await db.storage().encrypt(keys[0].key, note2);
|
||||
|
||||
const chunk = {
|
||||
type: "note",
|
||||
count: 2,
|
||||
items: [
|
||||
{ ...cipher1, id: "note1", v: 5, keyVersion: keys[0].version },
|
||||
{
|
||||
...cipher2,
|
||||
id: "note2",
|
||||
v: 5,
|
||||
keyVersion: keys.length > 1 ? keys[1].version : keys[0].version
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Process the chunk with mixed key versions
|
||||
await sync.processChunk(chunk, keys, { type: "fetch" });
|
||||
|
||||
// Verify both notes were decrypted correctly
|
||||
const savedNote1 = await db.notes.note("note1");
|
||||
const savedNote2 = await db.notes.note("note2");
|
||||
|
||||
expect(savedNote1).toBeDefined();
|
||||
expect(savedNote2).toBeDefined();
|
||||
expect(savedNote1.title).toBe("Note 1");
|
||||
expect(savedNote2.title).toBe("Note 2");
|
||||
}));
|
||||
|
||||
test("sync should maintain stable ordering across decryptMulti", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const collector = new Collector(db);
|
||||
|
||||
// Create multiple notes with predictable order
|
||||
const noteIds = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = await db.notes.add({
|
||||
...TEST_NOTE,
|
||||
title: `Note ${i}`
|
||||
});
|
||||
noteIds.push(id);
|
||||
}
|
||||
|
||||
const items = await iteratorToArray(collector.collect(100, false));
|
||||
const noteChunk = items.find((i) => i.type === "note");
|
||||
|
||||
expect(noteChunk).toBeDefined();
|
||||
expect(noteChunk.items).toHaveLength(5);
|
||||
|
||||
// Verify all items have IDs
|
||||
const collectedIds = noteChunk.items.map((item) => item.id);
|
||||
expect(collectedIds).toHaveLength(5);
|
||||
|
||||
// All IDs should be present
|
||||
for (const id of noteIds) {
|
||||
expect(collectedIds).toContain(id);
|
||||
}
|
||||
|
||||
// Decrypt and verify ID mapping is preserved
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
const { Sync } = await import("../index.ts");
|
||||
const sync = new Sync(db);
|
||||
|
||||
await sync.processChunk(noteChunk, keys, { type: "fetch" });
|
||||
|
||||
// Verify each note can be retrieved with correct content
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const note = await db.notes.note(noteIds[i]);
|
||||
expect(note).toBeDefined();
|
||||
expect(note.title).toBe(`Note ${i}`);
|
||||
}
|
||||
}));
|
||||
|
||||
test("sync should correctly select key based on keyVersion", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await loginFakeUser(db);
|
||||
const { Sync } = await import("../index.ts");
|
||||
const sync = new Sync(db);
|
||||
|
||||
const keys = await db.user.getDataEncryptionKeys();
|
||||
|
||||
// Create items encrypted with specific key versions
|
||||
const testCases = keys.map((keyInfo, idx) => ({
|
||||
id: `note${idx}`,
|
||||
title: `Note with keyVersion ${keyInfo.version}`,
|
||||
keyVersion: keyInfo.version,
|
||||
key: keyInfo.key
|
||||
}));
|
||||
|
||||
const chunks = [];
|
||||
for (const testCase of testCases) {
|
||||
const noteData = JSON.stringify({
|
||||
id: testCase.id,
|
||||
type: "note",
|
||||
title: testCase.title,
|
||||
dateModified: Date.now()
|
||||
});
|
||||
const cipher = await db.storage().encrypt(testCase.key, noteData);
|
||||
|
||||
chunks.push({
|
||||
type: "note",
|
||||
count: 1,
|
||||
items: [
|
||||
{ ...cipher, id: testCase.id, v: 5, keyVersion: testCase.keyVersion }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Process each chunk
|
||||
for (const chunk of chunks) {
|
||||
await sync.processChunk(chunk, keys, { type: "fetch" });
|
||||
}
|
||||
|
||||
// Verify each note was decrypted with the correct key
|
||||
for (const testCase of testCases) {
|
||||
const note = await db.notes.note(testCase.id);
|
||||
expect(note).toBeDefined();
|
||||
expect(note.title).toBe(testCase.title);
|
||||
}
|
||||
}));
|
||||
|
||||
async function iteratorToArray(iterator) {
|
||||
let items = [];
|
||||
for await (const item of iterator) {
|
||||
|
||||
@@ -19,13 +19,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { Cipher } from "@notesnook/crypto";
|
||||
import Database from "../index.js";
|
||||
import { CURRENT_DATABASE_VERSION, EV, EVENTS } from "../../common.js";
|
||||
import { CURRENT_DATABASE_VERSION, EVENTS } from "../../common.js";
|
||||
import { logger } from "../../logger.js";
|
||||
import {
|
||||
SyncItem,
|
||||
SyncTransferItem,
|
||||
SYNC_COLLECTIONS_MAP,
|
||||
SYNC_ITEM_TYPES
|
||||
SYNC_ITEM_TYPES,
|
||||
KeyVersion
|
||||
} from "./types.js";
|
||||
import { Item, MaybeDeletedItem } from "../../types.js";
|
||||
|
||||
@@ -46,12 +47,17 @@ class Collector {
|
||||
chunkSize: number,
|
||||
isForceSync = false
|
||||
): AsyncGenerator<SyncTransferItem, void, unknown> {
|
||||
const key = await this.db.user.getEncryptionKey();
|
||||
if (!key || !key.key || !key.salt) {
|
||||
EV.publish(EVENTS.userSessionExpired);
|
||||
const keys = await this.db.user.getDataEncryptionKeys();
|
||||
if (!keys || !keys.length) {
|
||||
this.db.eventManager.publish(EVENTS.userSessionExpired);
|
||||
throw new Error("User encryption key not generated. Please relogin.");
|
||||
}
|
||||
|
||||
// select the latest available key for encryption
|
||||
const key = keys.reduce((max, current) =>
|
||||
current.version > max.version ? current : max
|
||||
);
|
||||
|
||||
for (const itemType of SYNC_ITEM_TYPES) {
|
||||
const collectionKey = SYNC_COLLECTIONS_MAP[itemType];
|
||||
const collection = this.db[collectionKey].collection;
|
||||
@@ -61,8 +67,8 @@ class Collector {
|
||||
if (!ids.length) continue;
|
||||
const ciphers = await this.db
|
||||
.storage()
|
||||
.encryptMulti(key, syncableItems);
|
||||
const items = toSyncItem(ids, ciphers);
|
||||
.encryptMulti(key.key, syncableItems);
|
||||
const items = toSyncItem(ids, ciphers, key.version);
|
||||
if (!items.length) continue;
|
||||
yield { items, type: itemType, count: items.length };
|
||||
|
||||
@@ -88,7 +94,11 @@ class Collector {
|
||||
}
|
||||
export default Collector;
|
||||
|
||||
function toSyncItem(ids: string[], ciphers: Cipher<"base64">[]) {
|
||||
function toSyncItem(
|
||||
ids: string[],
|
||||
ciphers: Cipher<"base64">[],
|
||||
keyVersion: KeyVersion
|
||||
) {
|
||||
if (ids.length !== ciphers.length)
|
||||
throw new Error("ids.length must be equal to ciphers.length");
|
||||
|
||||
@@ -98,6 +108,7 @@ function toSyncItem(ids: string[], ciphers: Cipher<"base64">[]) {
|
||||
const cipher = ciphers[i] as SyncItem;
|
||||
cipher.v = CURRENT_DATABASE_VERSION;
|
||||
cipher.id = id;
|
||||
cipher.keyVersion = keyVersion;
|
||||
items.push(cipher);
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -20,7 +20,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import {
|
||||
checkSyncStatus,
|
||||
CURRENT_DATABASE_VERSION,
|
||||
EV,
|
||||
EVENTS,
|
||||
sendSyncProgressEvent,
|
||||
SYNC_CHECK_IDS
|
||||
@@ -47,6 +46,8 @@ import {
|
||||
Notebook
|
||||
} from "../../types.js";
|
||||
import {
|
||||
KEY_VERSION,
|
||||
KeyVersion,
|
||||
SYNC_COLLECTIONS_MAP,
|
||||
SyncableItemType,
|
||||
SyncInboxItem,
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
import { DownloadableFile } from "../../database/fs.js";
|
||||
import { SyncDevices } from "./devices.js";
|
||||
import { DefaultColors } from "../../collections/colors.js";
|
||||
import { Monographs } from "../monographs.js";
|
||||
|
||||
enum LogLevel {
|
||||
/** Log level for very low severity diagnostic messages. */
|
||||
@@ -103,7 +103,7 @@ export default class SyncManager {
|
||||
|
||||
async start(options: SyncOptions) {
|
||||
try {
|
||||
if (await checkSyncStatus(SYNC_CHECK_IDS.autoSync))
|
||||
if (await checkSyncStatus(this.db.eventManager, SYNC_CHECK_IDS.autoSync))
|
||||
await this.sync.autoSync.start();
|
||||
await this.sync.start(options);
|
||||
return true;
|
||||
@@ -149,7 +149,7 @@ export default class SyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
class Sync {
|
||||
export class Sync {
|
||||
collector;
|
||||
merger;
|
||||
autoSync;
|
||||
@@ -176,7 +176,7 @@ class Sync {
|
||||
await this.createConnection(options);
|
||||
if (!this.connection) return;
|
||||
|
||||
if (!(await checkSyncStatus(SYNC_CHECK_IDS.sync))) {
|
||||
if (!(await checkSyncStatus(this.db.eventManager, SYNC_CHECK_IDS.sync))) {
|
||||
await this.connection.stop();
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +206,9 @@ class Sync {
|
||||
|
||||
await this.stop(options);
|
||||
|
||||
if (!(await checkSyncStatus(SYNC_CHECK_IDS.autoSync))) {
|
||||
if (
|
||||
!(await checkSyncStatus(this.db.eventManager, SYNC_CHECK_IDS.autoSync))
|
||||
) {
|
||||
await this.connection.stop();
|
||||
this.autoSync.stop();
|
||||
}
|
||||
@@ -245,7 +247,7 @@ class Sync {
|
||||
"RequestFetchV3 failed, falling back to RequestFetchV2"
|
||||
);
|
||||
await this.connection?.invoke("RequestFetchV2", deviceId);
|
||||
}
|
||||
} else throw error;
|
||||
}
|
||||
|
||||
if (this.conflictedNoteIds.length > 0) {
|
||||
@@ -343,16 +345,51 @@ class Sync {
|
||||
|
||||
async processChunk(
|
||||
chunk: SyncTransferItem,
|
||||
key: SerializedKey,
|
||||
keys: {
|
||||
version: KeyVersion;
|
||||
key: SerializedKey;
|
||||
}[],
|
||||
options: SyncOptions
|
||||
) {
|
||||
const itemType = chunk.type;
|
||||
const decrypted = await this.db.storage().decryptMulti(key, chunk.items);
|
||||
const decrypted: string[] = [];
|
||||
|
||||
// Pre-group items by keyVersion for O(1) lookups
|
||||
const itemsByKeyVersion = new Map<KeyVersion, typeof chunk.items>();
|
||||
const versionMap = new Map<string, number>();
|
||||
|
||||
for (const item of chunk.items) {
|
||||
const keyVersion = item.keyVersion ?? KEY_VERSION.LEGACY;
|
||||
const group = itemsByKeyVersion.get(keyVersion);
|
||||
if (group) {
|
||||
group.push(item);
|
||||
} else {
|
||||
itemsByKeyVersion.set(keyVersion, [item]);
|
||||
}
|
||||
versionMap.set(item.id, item.v);
|
||||
}
|
||||
|
||||
for (const keyInfo of keys) {
|
||||
const itemsToDecrypt = itemsByKeyVersion.get(keyInfo.version);
|
||||
if (!itemsToDecrypt || itemsToDecrypt.length === 0) continue;
|
||||
|
||||
decrypted.push(
|
||||
...(await this.db.storage().decryptMulti(keyInfo.key, itemsToDecrypt))
|
||||
);
|
||||
}
|
||||
|
||||
const deserialized: MaybeDeletedItem<Item>[] = [];
|
||||
for (let i = 0; i < decrypted.length; ++i) {
|
||||
const decryptedItem = decrypted[i];
|
||||
const version = chunk.items[i].v;
|
||||
const decryptedItem = JSON.parse(decrypted[i]) as MaybeDeletedItem<Item>;
|
||||
const version = versionMap.get(decryptedItem.id);
|
||||
if (version === undefined) {
|
||||
this.logger.error(
|
||||
new Error(
|
||||
`Version not found for item ${decryptedItem.id}. Skipping item.`
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const item = await deserializeItem(
|
||||
decryptedItem,
|
||||
itemType,
|
||||
@@ -418,7 +455,7 @@ class Sync {
|
||||
const { HubConnectionBuilder, HttpTransportType, JsonHubProtocol } =
|
||||
await import("@microsoft/signalr");
|
||||
|
||||
const tokenManager = new TokenManager(this.db.kv);
|
||||
const tokenManager = new TokenManager(this.db.kv, this.db.eventManager);
|
||||
this.connection = new HubConnectionBuilder()
|
||||
.withUrl(`${Constants.API_HOST}/hubs/sync/v2`, {
|
||||
accessTokenFactory: async () => {
|
||||
@@ -476,10 +513,15 @@ class Sync {
|
||||
this.connection.on("SendItems", async (chunk) => {
|
||||
if (this.connection?.state !== HubConnectionState.Connected) return false;
|
||||
|
||||
const key = await this.getKey();
|
||||
if (!key) return false;
|
||||
|
||||
await this.processChunk(chunk, key, options);
|
||||
const keys = await this.db.user.getDataEncryptionKeys();
|
||||
if (!keys || !keys.length) {
|
||||
this.logger.error(
|
||||
new Error("User encryption keys not generated. Please relogin.")
|
||||
);
|
||||
this.db.eventManager.publish(EVENTS.userSessionExpired);
|
||||
return false;
|
||||
}
|
||||
await this.processChunk(chunk, keys, options);
|
||||
|
||||
sendSyncProgressEvent(this.db.eventManager, `download`, chunk.count);
|
||||
|
||||
@@ -513,18 +555,6 @@ class Sync {
|
||||
);
|
||||
}
|
||||
|
||||
private async getKey() {
|
||||
const key = await this.db.user.getEncryptionKey();
|
||||
if (!key?.key) {
|
||||
this.logger.error(
|
||||
new Error("User encryption key not generated. Please relogin.")
|
||||
);
|
||||
EV.publish(EVENTS.userSessionExpired);
|
||||
return;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private async checkConnection() {
|
||||
await this.syncConnectionMutex.runExclusive(async () => {
|
||||
try {
|
||||
@@ -564,12 +594,11 @@ function promiseTimeout(ms: number, promise: Promise<unknown>) {
|
||||
}
|
||||
|
||||
async function deserializeItem(
|
||||
decryptedItem: string,
|
||||
item: MaybeDeletedItem<Item>,
|
||||
type: SyncableItemType,
|
||||
version: number,
|
||||
database: Database
|
||||
): Promise<MaybeDeletedItem<Item> | undefined> {
|
||||
const item = JSON.parse(decryptedItem) as MaybeDeletedItem<Item>;
|
||||
item.remote = true;
|
||||
item.synced = true;
|
||||
|
||||
|
||||
@@ -19,9 +19,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { Cipher } from "@notesnook/crypto";
|
||||
|
||||
export const KEY_VERSION = {
|
||||
LEGACY: 0,
|
||||
DEK: 1
|
||||
} as const;
|
||||
|
||||
export type KeyVersion = (typeof KEY_VERSION)[keyof typeof KEY_VERSION];
|
||||
|
||||
export type SyncItem = {
|
||||
id: string;
|
||||
v: number;
|
||||
keyVersion?: KeyVersion;
|
||||
} & Cipher<"base64">;
|
||||
|
||||
export type SyncableItemType = keyof typeof SYNC_COLLECTIONS_MAP;
|
||||
|
||||
@@ -19,10 +19,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import http from "../utils/http.js";
|
||||
import constants from "../utils/constants.js";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { EVENTS } from "../common.js";
|
||||
import { withTimeout, Mutex } from "async-mutex";
|
||||
import { logger } from "../logger.js";
|
||||
import { KVStorageAccessor } from "../interfaces.js";
|
||||
import EventManager from "../utils/event-manager.js";
|
||||
|
||||
export type Token = {
|
||||
access_token: string;
|
||||
@@ -55,7 +56,10 @@ class TokenManager {
|
||||
new Error("Timed out while refreshing access token.")
|
||||
);
|
||||
|
||||
constructor(private readonly storage: KVStorageAccessor) {}
|
||||
constructor(
|
||||
private readonly storage: KVStorageAccessor,
|
||||
private readonly eventManager: EventManager
|
||||
) {}
|
||||
|
||||
async getToken(renew = true, forceRenew = false): Promise<Token | undefined> {
|
||||
const token = await this.storage().read("token");
|
||||
@@ -92,12 +96,16 @@ class TokenManager {
|
||||
scopes: Scope[] = ["notesnook.sync", "IdentityServerApi"],
|
||||
forceRenew = false
|
||||
) {
|
||||
return await getSafeToken(async () => {
|
||||
const token = await this.getToken(true, forceRenew);
|
||||
if (!token || !token.scope) return;
|
||||
if (!scopes.some((s) => token.scope.includes(s))) return;
|
||||
return token.access_token;
|
||||
}, "Error getting access token:");
|
||||
return await getSafeToken(
|
||||
async () => {
|
||||
const token = await this.getToken(true, forceRenew);
|
||||
if (!token || !token.scope) return;
|
||||
if (!scopes.some((s) => token.scope.includes(s))) return;
|
||||
return token.access_token;
|
||||
},
|
||||
"Error getting access token:",
|
||||
this.eventManager
|
||||
);
|
||||
}
|
||||
|
||||
async _refreshToken(forceRenew = false) {
|
||||
@@ -112,7 +120,7 @@ class TokenManager {
|
||||
|
||||
const { refresh_token, scope } = token;
|
||||
if (!refresh_token || !scope) {
|
||||
EV.publish(EVENTS.userSessionExpired);
|
||||
this.eventManager.publish(EVENTS.userSessionExpired);
|
||||
this.logger.error(new Error("Token not found."));
|
||||
return;
|
||||
}
|
||||
@@ -127,7 +135,7 @@ class TokenManager {
|
||||
}
|
||||
);
|
||||
await this.saveToken(refreshTokenResponse);
|
||||
EV.publish(EVENTS.tokenRefreshed);
|
||||
this.eventManager.publish(EVENTS.tokenRefreshed);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,7 +171,11 @@ class TokenManager {
|
||||
}
|
||||
export default TokenManager;
|
||||
|
||||
async function getSafeToken<T>(action: () => Promise<T>, errorMessage: string) {
|
||||
async function getSafeToken<T>(
|
||||
action: () => Promise<T>,
|
||||
errorMessage: string,
|
||||
eventManager: EventManager
|
||||
) {
|
||||
try {
|
||||
return await action();
|
||||
} catch (e) {
|
||||
@@ -172,7 +184,7 @@ async function getSafeToken<T>(action: () => Promise<T>, errorMessage: string) {
|
||||
e instanceof Error &&
|
||||
(e.message === "invalid_grant" || e.message === "invalid_client")
|
||||
) {
|
||||
EV.publish(EVENTS.userSessionExpired);
|
||||
eventManager.publish(EVENTS.userSessionExpired);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,15 @@ import TokenManager from "./token-manager.js";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { HealthCheck } from "./healthcheck.js";
|
||||
import Database from "./index.js";
|
||||
import { SerializedKeyPair, SerializedKey, Cipher } from "@notesnook/crypto";
|
||||
import { SerializedKeyPair, SerializedKey } from "@notesnook/crypto";
|
||||
import { logger } from "../logger.js";
|
||||
import { KEY_VERSION, KeyVersion } from "./sync/types.js";
|
||||
import {
|
||||
KeyId,
|
||||
KeyManager,
|
||||
KeyTypeFromId,
|
||||
UnwrapKeyReturnType
|
||||
} from "./key-manager.js";
|
||||
|
||||
const ENDPOINTS = {
|
||||
signup: "/users",
|
||||
@@ -42,11 +49,10 @@ const ENDPOINTS = {
|
||||
|
||||
class UserManager {
|
||||
private tokenManager: TokenManager;
|
||||
private cachedAttachmentKey?: SerializedKey;
|
||||
private cachedMonographPasswordsKey?: SerializedKey;
|
||||
private cachedInboxKeys?: SerializedKeyPair;
|
||||
private keyManager: KeyManager;
|
||||
constructor(private readonly db: Database) {
|
||||
this.tokenManager = new TokenManager(this.db.kv);
|
||||
this.keyManager = new KeyManager(db);
|
||||
this.tokenManager = new TokenManager(db.kv, db.eventManager);
|
||||
|
||||
EV.subscribe(EVENTS.userUnauthorized, async (url: string) => {
|
||||
if (url.includes("/connect/token") || !(await HealthCheck.auth())) return;
|
||||
@@ -75,13 +81,35 @@ class UserManager {
|
||||
email = email.toLowerCase();
|
||||
|
||||
const hashedPassword = await this.db.storage().hash(password, email);
|
||||
await http.post(`${constants.API_HOST}${ENDPOINTS.signup}`, {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
client_id: "notesnook"
|
||||
await this.tokenManager.saveToken(
|
||||
await http.post(`${constants.API_HOST}${ENDPOINTS.signup}`, {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
client_id: "notesnook"
|
||||
})
|
||||
);
|
||||
|
||||
const user = await this.fetchUser();
|
||||
if (!user) throw new Error("Failed to fetch user after signup.");
|
||||
|
||||
await this.db.setLastSynced(0);
|
||||
await this.db.syncer.devices.register();
|
||||
|
||||
await this.db.storage().deriveCryptoKey({
|
||||
password,
|
||||
salt: user.salt
|
||||
});
|
||||
EV.publish(EVENTS.userSignedUp);
|
||||
return await this._login({ email, password, hashedPassword });
|
||||
|
||||
const masterKey = await this.getMasterKey();
|
||||
if (!masterKey) throw new Error("User encryption key not generated.");
|
||||
await this.updateUser({
|
||||
dataEncryptionKey: await this.keyManager.wrapKey(
|
||||
await this.db.crypto().generateRandomKey(),
|
||||
masterKey
|
||||
)
|
||||
});
|
||||
|
||||
this.db.eventManager.publish(EVENTS.userLoggedIn, user);
|
||||
}
|
||||
|
||||
async authenticateEmail(email: string) {
|
||||
@@ -199,50 +227,6 @@ class UserManager {
|
||||
}
|
||||
}
|
||||
|
||||
async _login({
|
||||
email,
|
||||
password,
|
||||
hashedPassword,
|
||||
code,
|
||||
method
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
hashedPassword?: string;
|
||||
code?: string;
|
||||
method?: string;
|
||||
}) {
|
||||
email = email && email.toLowerCase();
|
||||
|
||||
if (!hashedPassword && password) {
|
||||
hashedPassword = await this.db.storage().hash(password, email);
|
||||
}
|
||||
|
||||
await this.tokenManager.saveToken(
|
||||
await http.post(`${constants.AUTH_HOST}${ENDPOINTS.token}`, {
|
||||
username: email,
|
||||
password: hashedPassword,
|
||||
grant_type: code ? "mfa" : "password",
|
||||
scope: "notesnook.sync offline_access IdentityServerApi",
|
||||
client_id: "notesnook",
|
||||
"mfa:code": code,
|
||||
"mfa:method": method
|
||||
})
|
||||
);
|
||||
|
||||
const user = await this.fetchUser();
|
||||
if (!user) return;
|
||||
|
||||
await this.db.storage().deriveCryptoKey({
|
||||
password,
|
||||
salt: user.salt
|
||||
});
|
||||
await this.db.setLastSynced(0);
|
||||
await this.db.syncer.devices.register();
|
||||
|
||||
this.db.eventManager.publish(EVENTS.userLoggedIn, user);
|
||||
}
|
||||
|
||||
async getSessions() {
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
if (!token) return;
|
||||
@@ -278,8 +262,7 @@ class UserManager {
|
||||
} catch (e) {
|
||||
logger.error(e, "Error logging out user.", { revoke, reason });
|
||||
} finally {
|
||||
this.cachedAttachmentKey = undefined;
|
||||
this.cachedInboxKeys = undefined;
|
||||
this.keyManager.clearCache();
|
||||
await this.db.reset();
|
||||
this.db.eventManager.publish(EVENTS.userLoggedOut, reason);
|
||||
this.db.eventManager.publish(EVENTS.appRefreshRequested);
|
||||
@@ -381,7 +364,7 @@ class UserManager {
|
||||
}
|
||||
|
||||
changePassword(oldPassword: string, newPassword: string) {
|
||||
return this._updatePassword("change_password", {
|
||||
return this._updatePassword("change", {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
@@ -402,12 +385,46 @@ class UserManager {
|
||||
}
|
||||
|
||||
resetPassword(newPassword: string) {
|
||||
return this._updatePassword("reset_password", {
|
||||
return this._updatePassword("reset", {
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
async getEncryptionKey(): Promise<SerializedKey | undefined> {
|
||||
async getDataEncryptionKeys(): Promise<
|
||||
{ version: KeyVersion; key: SerializedKey }[] | undefined
|
||||
> {
|
||||
const masterKey = await this.getMasterKey();
|
||||
if (!masterKey) return;
|
||||
|
||||
const dataEncryptionKey = await this.keyManager.get("dataEncryptionKey");
|
||||
if (!dataEncryptionKey)
|
||||
return [
|
||||
{
|
||||
key: masterKey,
|
||||
version: KEY_VERSION.LEGACY
|
||||
}
|
||||
];
|
||||
const keys: { version: KeyVersion; key: SerializedKey }[] = [];
|
||||
|
||||
const legacyDataEncryptionKey = await this.keyManager.get(
|
||||
"legacyDataEncryptionKey"
|
||||
);
|
||||
if (legacyDataEncryptionKey)
|
||||
keys.push({
|
||||
key: await this.keyManager.unwrapKey(
|
||||
legacyDataEncryptionKey,
|
||||
masterKey
|
||||
),
|
||||
version: KEY_VERSION.LEGACY
|
||||
});
|
||||
keys.push({
|
||||
key: await this.keyManager.unwrapKey(dataEncryptionKey, masterKey),
|
||||
version: KEY_VERSION.DEK
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
async getMasterKey(): Promise<SerializedKey | undefined> {
|
||||
const user = await this.getUser();
|
||||
if (!user) return;
|
||||
const key = await this.db.storage().getCryptoKey();
|
||||
@@ -426,44 +443,31 @@ class UserManager {
|
||||
return { key, salt: user.salt };
|
||||
}
|
||||
|
||||
private async getUserKey<T>(config: {
|
||||
getCache: () => T | undefined;
|
||||
setCache: (key: T) => void;
|
||||
userProperty: keyof User;
|
||||
generateKey: () => Promise<T>;
|
||||
errorContext: string;
|
||||
decrypt: (user: User, userEncryptionKey: SerializedKey) => Promise<T>;
|
||||
encrypt: (
|
||||
key: T,
|
||||
userEncryptionKey: SerializedKey
|
||||
) => Promise<Partial<User>>;
|
||||
}): Promise<T | undefined> {
|
||||
const cachedKey = config.getCache();
|
||||
if (cachedKey) return cachedKey;
|
||||
|
||||
private async getUserKey<TId extends KeyId>(
|
||||
id: TId,
|
||||
config: {
|
||||
generateKey: () => Promise<SerializedKey | SerializedKeyPair>;
|
||||
errorContext: string;
|
||||
}
|
||||
): Promise<UnwrapKeyReturnType<KeyTypeFromId<TId>> | undefined> {
|
||||
try {
|
||||
let user = await this.getUser();
|
||||
if (!user) return;
|
||||
const masterKey = await this.getMasterKey();
|
||||
if (!masterKey) return;
|
||||
|
||||
if (!user[config.userProperty]) {
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
user = await http.get(`${constants.API_HOST}${ENDPOINTS.user}`, token);
|
||||
}
|
||||
if (!user) return;
|
||||
const wrappedKey = await this.keyManager.get(id);
|
||||
|
||||
const userEncryptionKey = await this.getEncryptionKey();
|
||||
if (!userEncryptionKey) return;
|
||||
|
||||
if (!user[config.userProperty]) {
|
||||
if (!wrappedKey) {
|
||||
const key = await config.generateKey();
|
||||
const updatePayload = await config.encrypt(key, userEncryptionKey);
|
||||
await this.updateUser(updatePayload);
|
||||
return key;
|
||||
await this.updateUser({
|
||||
[id]: await this.keyManager.wrapKey(key, masterKey)
|
||||
});
|
||||
return key as UnwrapKeyReturnType<KeyTypeFromId<TId>>;
|
||||
}
|
||||
|
||||
const decryptedKey = await config.decrypt(user, userEncryptionKey);
|
||||
config.setCache(decryptedKey);
|
||||
return decryptedKey;
|
||||
return (await this.keyManager.unwrapKey(
|
||||
wrappedKey,
|
||||
masterKey
|
||||
)) as UnwrapKeyReturnType<KeyTypeFromId<TId>>;
|
||||
} catch (e) {
|
||||
logger.error(e, `Could not get ${config.errorContext}.`);
|
||||
if (e instanceof Error)
|
||||
@@ -474,94 +478,27 @@ class UserManager {
|
||||
}
|
||||
|
||||
async getAttachmentsKey() {
|
||||
return this.getUserKey<SerializedKey>({
|
||||
getCache: () => this.cachedAttachmentKey,
|
||||
setCache: (key) => {
|
||||
this.cachedAttachmentKey = key;
|
||||
},
|
||||
userProperty: "attachmentsKey",
|
||||
return this.getUserKey("attachmentsKey", {
|
||||
generateKey: () => this.db.crypto().generateRandomKey(),
|
||||
errorContext: "attachments encryption key",
|
||||
encrypt: async (key, userEncryptionKey) => {
|
||||
const encryptedKey = await this.db
|
||||
.storage()
|
||||
.encrypt(userEncryptionKey, JSON.stringify(key));
|
||||
return { attachmentsKey: encryptedKey };
|
||||
},
|
||||
decrypt: async (user, userEncryptionKey) => {
|
||||
const encryptedKey = user.attachmentsKey as Cipher<"base64">;
|
||||
const plainData = await this.db
|
||||
.storage()
|
||||
.decrypt(userEncryptionKey, encryptedKey);
|
||||
if (!plainData) throw new Error("Failed to decrypt attachments key");
|
||||
return JSON.parse(plainData) as SerializedKey;
|
||||
}
|
||||
errorContext: "attachments encryption key"
|
||||
});
|
||||
}
|
||||
|
||||
async getMonographPasswordsKey() {
|
||||
return this.getUserKey<SerializedKey>({
|
||||
getCache: () => this.cachedMonographPasswordsKey,
|
||||
setCache: (key) => {
|
||||
this.cachedMonographPasswordsKey = key;
|
||||
},
|
||||
userProperty: "monographPasswordsKey",
|
||||
return this.getUserKey("monographPasswordsKey", {
|
||||
generateKey: () => this.db.crypto().generateRandomKey(),
|
||||
errorContext: "monographs encryption key",
|
||||
encrypt: async (key, userEncryptionKey) => {
|
||||
const encryptedKey = await this.db
|
||||
.storage()
|
||||
.encrypt(userEncryptionKey, JSON.stringify(key));
|
||||
return { monographPasswordsKey: encryptedKey };
|
||||
},
|
||||
decrypt: async (user, userEncryptionKey) => {
|
||||
const encryptedKey = user.monographPasswordsKey as Cipher<"base64">;
|
||||
const plainData = await this.db
|
||||
.storage()
|
||||
.decrypt(userEncryptionKey, encryptedKey);
|
||||
if (!plainData)
|
||||
throw new Error("Failed to decrypt monograph passwords key");
|
||||
return JSON.parse(plainData) as SerializedKey;
|
||||
}
|
||||
errorContext: "monographs encryption key"
|
||||
});
|
||||
}
|
||||
|
||||
async getInboxKeys() {
|
||||
return this.getUserKey<SerializedKeyPair>({
|
||||
getCache: () => this.cachedInboxKeys,
|
||||
setCache: (key) => {
|
||||
this.cachedInboxKeys = key;
|
||||
},
|
||||
userProperty: "inboxKeys",
|
||||
return this.getUserKey("inboxKeys", {
|
||||
generateKey: () => this.db.crypto().generateCryptoKeyPair(),
|
||||
errorContext: "inbox encryption keys",
|
||||
encrypt: async (keys, userEncryptionKey) => {
|
||||
const encryptedPrivateKey = await this.db
|
||||
.storage()
|
||||
.encrypt(userEncryptionKey, JSON.stringify(keys.privateKey));
|
||||
return {
|
||||
inboxKeys: {
|
||||
public: keys.publicKey,
|
||||
private: encryptedPrivateKey
|
||||
}
|
||||
};
|
||||
},
|
||||
decrypt: async (user, userEncryptionKey) => {
|
||||
if (!user.inboxKeys) throw new Error("Inbox keys not found");
|
||||
const decryptedPrivateKey = await this.db
|
||||
.storage()
|
||||
.decrypt(userEncryptionKey, user.inboxKeys.private);
|
||||
return {
|
||||
publicKey: user.inboxKeys.public,
|
||||
privateKey: JSON.parse(decryptedPrivateKey)
|
||||
};
|
||||
}
|
||||
errorContext: "inbox encryption keys"
|
||||
});
|
||||
}
|
||||
|
||||
async hasInboxKeys() {
|
||||
if (this.cachedInboxKeys) return true;
|
||||
|
||||
const user = await this.getUser();
|
||||
if (!user) return false;
|
||||
|
||||
@@ -569,7 +506,7 @@ class UserManager {
|
||||
}
|
||||
|
||||
async discardInboxKeys() {
|
||||
this.cachedInboxKeys = undefined;
|
||||
this.keyManager.clearCache();
|
||||
|
||||
const user = await this.getUser();
|
||||
if (!user) return;
|
||||
@@ -627,19 +564,20 @@ class UserManager {
|
||||
async verifyPassword(password: string) {
|
||||
try {
|
||||
const user = await this.getUser();
|
||||
const key = await this.getEncryptionKey();
|
||||
const key = await this.getMasterKey();
|
||||
if (!user || !key) return false;
|
||||
|
||||
const cipher = await this.db.storage().encrypt(key, "notesnook");
|
||||
const plainText = await this.db.storage().decrypt({ password }, cipher);
|
||||
return plainText === "notesnook";
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async _updatePassword(
|
||||
type: "change_password" | "reset_password",
|
||||
type: "change" | "reset",
|
||||
data: {
|
||||
new_password: string;
|
||||
old_password?: string;
|
||||
@@ -652,98 +590,95 @@ class UserManager {
|
||||
|
||||
const { email, salt } = user;
|
||||
|
||||
let { new_password, old_password } = data;
|
||||
const { new_password, old_password } = data;
|
||||
if (old_password && !(await this.verifyPassword(old_password)))
|
||||
throw new Error("Incorrect old password.");
|
||||
|
||||
const oldPassword = old_password
|
||||
? await this.db.storage().hash(old_password, email, {
|
||||
usesFallback: await this.usesFallbackPWHash(old_password)
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!new_password) throw new Error("New password is required.");
|
||||
|
||||
data.encryptionKey = data.encryptionKey || (await this.getEncryptionKey());
|
||||
data.encryptionKey = data.encryptionKey || (await this.getMasterKey());
|
||||
|
||||
await this.clearSessions();
|
||||
const updateUserPayload: Partial<User> = {};
|
||||
if (data.encryptionKey) {
|
||||
const newMasterKey = await this.db
|
||||
.storage()
|
||||
.generateCryptoKey(new_password, salt);
|
||||
if (user.attachmentsKey) {
|
||||
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
|
||||
user.attachmentsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.monographPasswordsKey) {
|
||||
updateUserPayload.monographPasswordsKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.monographPasswordsKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
if (user.inboxKeys) {
|
||||
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
|
||||
user.inboxKeys,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
}
|
||||
|
||||
if (data.encryptionKey) await this.db.sync({ type: "fetch", force: true });
|
||||
if (user.legacyDataEncryptionKey)
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.rewrapKey(
|
||||
user.legacyDataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
if (user.dataEncryptionKey)
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
|
||||
user.dataEncryptionKey,
|
||||
data.encryptionKey,
|
||||
newMasterKey
|
||||
);
|
||||
else {
|
||||
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
|
||||
await this.db.crypto().generateRandomKey(),
|
||||
newMasterKey
|
||||
);
|
||||
updateUserPayload.legacyDataEncryptionKey =
|
||||
await this.keyManager.wrapKey(data.encryptionKey, newMasterKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (old_password)
|
||||
old_password = await this.db.storage().hash(old_password, email, {
|
||||
usesFallback: await this.usesFallbackPWHash(old_password)
|
||||
});
|
||||
|
||||
// retrieve user keys before deriving a new encryption key
|
||||
const oldUserKeys = {
|
||||
attachmentsKey: await this.getAttachmentsKey(),
|
||||
monographPasswordsKey: await this.getMonographPasswordsKey(),
|
||||
inboxKeys: (await this.hasInboxKeys())
|
||||
? await this.getInboxKeys()
|
||||
: undefined
|
||||
} as const;
|
||||
await http.patch.json(
|
||||
`${constants.API_HOST}/users/password/${type}`,
|
||||
{
|
||||
oldPassword: oldPassword,
|
||||
newPassword: await this.db.storage().hash(new_password, email),
|
||||
userKeys: updateUserPayload
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
await this.db.storage().deriveCryptoKey({
|
||||
password: new_password,
|
||||
salt
|
||||
});
|
||||
|
||||
if (!(await this.resetUser(false))) return;
|
||||
|
||||
await this.db.sync({ type: "send", force: true });
|
||||
|
||||
const userEncryptionKey = await this.getEncryptionKey();
|
||||
if (userEncryptionKey) {
|
||||
const updateUserPayload: Partial<User> = {};
|
||||
if (oldUserKeys.attachmentsKey) {
|
||||
user.attachmentsKey = await this.db
|
||||
.storage()
|
||||
.encrypt(
|
||||
userEncryptionKey,
|
||||
JSON.stringify(oldUserKeys.attachmentsKey)
|
||||
);
|
||||
updateUserPayload.attachmentsKey = user.attachmentsKey;
|
||||
}
|
||||
if (oldUserKeys.monographPasswordsKey) {
|
||||
user.monographPasswordsKey = await this.db
|
||||
.storage()
|
||||
.encrypt(
|
||||
userEncryptionKey,
|
||||
JSON.stringify(oldUserKeys.monographPasswordsKey)
|
||||
);
|
||||
updateUserPayload.monographPasswordsKey = user.monographPasswordsKey;
|
||||
}
|
||||
if (oldUserKeys.inboxKeys) {
|
||||
user.inboxKeys = {
|
||||
public: oldUserKeys.inboxKeys.publicKey,
|
||||
private: await this.db
|
||||
.storage()
|
||||
.encrypt(
|
||||
userEncryptionKey,
|
||||
JSON.stringify(oldUserKeys.inboxKeys.privateKey)
|
||||
)
|
||||
};
|
||||
updateUserPayload.inboxKeys = user.inboxKeys;
|
||||
}
|
||||
if (Object.keys(updateUserPayload).length > 0) {
|
||||
await this.updateUser(updateUserPayload);
|
||||
}
|
||||
}
|
||||
|
||||
if (new_password)
|
||||
new_password = await this.db.storage().hash(new_password, email);
|
||||
|
||||
await http.patch(
|
||||
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
|
||||
{
|
||||
type,
|
||||
old_password,
|
||||
new_password
|
||||
},
|
||||
token
|
||||
);
|
||||
this.keyManager.clearCache();
|
||||
await this.setUser({ ...user, ...updateUserPayload });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async usesFallbackPWHash(password: string) {
|
||||
const user = await this.getUser();
|
||||
const encryptionKey = await this.getEncryptionKey();
|
||||
const encryptionKey = await this.getMasterKey();
|
||||
if (!user || !encryptionKey) return false;
|
||||
const fallbackCryptoKey = await this.db
|
||||
.storage()
|
||||
|
||||
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { Cipher } from "@notesnook/crypto";
|
||||
import Database from "./index.js";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { EVENTS } from "../common.js";
|
||||
import { isCipher } from "../utils/crypto.js";
|
||||
import { Note, NoteContent } from "../types.js";
|
||||
import { logger } from "../logger.js";
|
||||
@@ -48,7 +48,7 @@ export default class Vault {
|
||||
}
|
||||
|
||||
private startEraser() {
|
||||
EV.publish(EVENTS.vaultUnlocked);
|
||||
this.db.eventManager.publish(EVENTS.vaultUnlocked);
|
||||
clearTimeout(this.erasureTimeout);
|
||||
this.erasureTimeout = setTimeout(() => {
|
||||
this.lock();
|
||||
@@ -80,7 +80,7 @@ export default class Vault {
|
||||
|
||||
async lock() {
|
||||
this.password = undefined;
|
||||
EV.publish(EVENTS.vaultLocked);
|
||||
this.db.eventManager.publish(EVENTS.vaultLocked);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { ICollection } from "./collection.js";
|
||||
import { getId } from "../utils/id.js";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { EVENTS } from "../common.js";
|
||||
import dataurl from "../utils/dataurl.js";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
@@ -48,7 +48,7 @@ export class Attachments implements ICollection {
|
||||
db.sanitizer
|
||||
);
|
||||
|
||||
EV.subscribe(
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.fileDownloaded,
|
||||
async ({
|
||||
success,
|
||||
@@ -68,7 +68,7 @@ export class Attachments implements ICollection {
|
||||
const src = await this.read(filename, getOutputType(attachment));
|
||||
if (!src) return;
|
||||
|
||||
EV.publish(EVENTS.mediaAttachmentDownloaded, {
|
||||
this.db.eventManager.publish(EVENTS.mediaAttachmentDownloaded, {
|
||||
groupId,
|
||||
hash: attachment.hash,
|
||||
attachmentType: getAttachmentType(attachment),
|
||||
@@ -77,7 +77,7 @@ export class Attachments implements ICollection {
|
||||
}
|
||||
);
|
||||
|
||||
EV.subscribe(
|
||||
db.eventManager.subscribe(
|
||||
EVENTS.fileUploaded,
|
||||
async ({
|
||||
success,
|
||||
@@ -226,6 +226,44 @@ export class Attachments implements ICollection {
|
||||
return false;
|
||||
}
|
||||
|
||||
async bulkRemove(attachments: Attachment[], localOnly: boolean) {
|
||||
logger.debug("Bulk removing attachments", {
|
||||
count: attachments.length,
|
||||
localOnly
|
||||
});
|
||||
if (attachments.length === 0) return;
|
||||
|
||||
const detachable: Attachment[] = [];
|
||||
for (const attachment of attachments) {
|
||||
if (!localOnly && !(await this.canDetach(attachment))) continue;
|
||||
detachable.push(attachment);
|
||||
}
|
||||
|
||||
const localOnlyHashes = detachable
|
||||
.filter((a) => localOnly || !a.dateUploaded)
|
||||
.map((a) => a.hash);
|
||||
const remoteHashes = detachable
|
||||
.filter((a) => !localOnly && !!a.dateUploaded)
|
||||
.map((a) => a.hash);
|
||||
|
||||
if (localOnlyHashes.length > 0) {
|
||||
await this.db.fs().bulkDeleteFiles(localOnlyHashes, true);
|
||||
}
|
||||
if (remoteHashes.length > 0) {
|
||||
await this.db.fs().bulkDeleteFiles(remoteHashes, false);
|
||||
}
|
||||
|
||||
if (!localOnly) {
|
||||
for (const attachment of detachable) {
|
||||
await this.detach(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
const ids = detachable.map((a) => a.id);
|
||||
await this.db.relations.unlinkOfType("attachment", ids);
|
||||
await this.collection.softDelete(ids);
|
||||
}
|
||||
|
||||
async detach(attachment: Attachment) {
|
||||
for (const note of await this.db.relations
|
||||
.to(attachment, "note")
|
||||
@@ -574,6 +612,14 @@ export class Attachments implements ICollection {
|
||||
);
|
||||
return this.key;
|
||||
}
|
||||
|
||||
async removeOrphaned() {
|
||||
const orphaned = await this.db.attachments.orphaned.items();
|
||||
logger.info("Deleting orphaned attachments", {
|
||||
attachments: orphaned
|
||||
});
|
||||
await this.bulkRemove(orphaned, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOutputType(attachment: Attachment): DataFormat {
|
||||
|
||||
@@ -397,7 +397,8 @@ class RelationsArray<TType extends keyof RelatableTable> {
|
||||
)
|
||||
.$if(
|
||||
!!this.types?.includes("note" as TType) &&
|
||||
this.db.notes.cache.archived.length > 0,
|
||||
this.db.notes.cache.archived.length > 0 &&
|
||||
this.reference.type !== "attachment",
|
||||
(b) => b.where("fromId", "not in", this.db.notes.cache.archived)
|
||||
)
|
||||
.$if(
|
||||
|
||||
@@ -121,8 +121,8 @@ export default class Trash {
|
||||
},
|
||||
{ noteIds: [] as string[], notebookIds: [] as string[] }
|
||||
);
|
||||
|
||||
await this._delete(noteIds, notebookIds);
|
||||
|
||||
await this.buildCache();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,14 @@ export const SYNC_CHECK_IDS = {
|
||||
|
||||
export type SyncStatusEvent = keyof typeof SYNC_CHECK_IDS;
|
||||
|
||||
export async function checkSyncStatus(type: string) {
|
||||
const results = await EV.publishWithResult<{ type: string; result: boolean }>(
|
||||
EVENTS.syncCheckStatus,
|
||||
type
|
||||
);
|
||||
export async function checkSyncStatus(
|
||||
eventManager: EventManager,
|
||||
type: string
|
||||
) {
|
||||
const results = await eventManager.publishWithResult<{
|
||||
type: string;
|
||||
result: boolean;
|
||||
}>(EVENTS.syncCheckStatus, type);
|
||||
if (typeof results === "boolean") return results;
|
||||
else if (typeof results === "undefined") return true;
|
||||
return results.some((r) => r.type === type && r.result === true);
|
||||
@@ -44,23 +47,23 @@ export type SyncProgressEvent = {
|
||||
};
|
||||
|
||||
export function sendSyncProgressEvent(
|
||||
EV: EventManager,
|
||||
eventManager: EventManager,
|
||||
type: string,
|
||||
current: number
|
||||
) {
|
||||
EV.publish(EVENTS.syncProgress, {
|
||||
eventManager.publish(EVENTS.syncProgress, {
|
||||
type,
|
||||
current
|
||||
} as SyncProgressEvent);
|
||||
}
|
||||
|
||||
export function sendMigrationProgressEvent(
|
||||
EV: EventManager,
|
||||
eventManager: EventManager,
|
||||
collection: string,
|
||||
total: number,
|
||||
current?: number
|
||||
) {
|
||||
EV.publish(EVENTS.migrationProgress, {
|
||||
eventManager.publish(EVENTS.migrationProgress, {
|
||||
collection,
|
||||
total,
|
||||
current: current === undefined ? total : current
|
||||
|
||||
@@ -363,6 +363,7 @@ export class Tiptap {
|
||||
break;
|
||||
}
|
||||
case "iframe":
|
||||
case "audio":
|
||||
case "span": {
|
||||
const hash = attr[ATTRIBUTES.hash];
|
||||
if (!hash) return;
|
||||
|
||||
@@ -309,8 +309,8 @@ export default class Backup {
|
||||
if (encrypt && !user)
|
||||
throw new Error("Please login to create encrypted backups.");
|
||||
|
||||
const key = await this.db.user.getEncryptionKey();
|
||||
if (encrypt && !key) throw new Error("No encryption key found.");
|
||||
const key = await this.db.user.getMasterKey();
|
||||
if (encrypt && !key) throw new Error("No master key found.");
|
||||
|
||||
yield {
|
||||
type: "file",
|
||||
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
IFileStorage
|
||||
} from "../interfaces.js";
|
||||
import { DataFormat, SerializedKey } from "@notesnook/crypto";
|
||||
import { EV, EVENTS } from "../common.js";
|
||||
import { EVENTS } from "../common.js";
|
||||
import { logger } from "../logger.js";
|
||||
import EventManager from "../utils/event-manager.js";
|
||||
|
||||
export type FileStorageAccessor = () => FileStorage;
|
||||
export type DownloadableFile = {
|
||||
@@ -48,7 +49,8 @@ export class FileStorage {
|
||||
|
||||
constructor(
|
||||
private readonly fs: IFileStorage,
|
||||
private readonly tokenManager: TokenManager
|
||||
private readonly tokenManager: TokenManager,
|
||||
private readonly eventManager: EventManager
|
||||
) {}
|
||||
|
||||
async queueDownloads(
|
||||
@@ -56,147 +58,173 @@ export class FileStorage {
|
||||
groupId: string,
|
||||
eventData?: Record<string, unknown>
|
||||
) {
|
||||
const newFiles = await this.fs.bulkExists(files.map((f) => f.filename));
|
||||
files = files.filter((f) => newFiles.includes(f.filename));
|
||||
if (files.length <= 0) return;
|
||||
try {
|
||||
const newFiles = await this.fs.bulkExists(files.map((f) => f.filename));
|
||||
files = files.filter((f) => newFiles.includes(f.filename));
|
||||
if (files.length <= 0) return;
|
||||
|
||||
let current = 0;
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
const total = files.length;
|
||||
const group = this.groups.downloads.get(groupId) || new Set();
|
||||
files.forEach((f) => group.add(f.filename));
|
||||
this.groups.downloads.set(groupId, group);
|
||||
let current = 0;
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
const total = files.length;
|
||||
|
||||
for (const file of files as QueueItem[]) {
|
||||
current++;
|
||||
if (!group.has(file.filename)) {
|
||||
EV.publish(EVENTS.fileDownloaded, {
|
||||
success: false,
|
||||
groupId,
|
||||
filename: file.filename,
|
||||
eventData,
|
||||
current,
|
||||
total
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const download = this.downloads.get(file.filename);
|
||||
if (download && download.operation) {
|
||||
logger.debug("[queueDownloads] duplicate download", {
|
||||
filename: file.filename,
|
||||
if (this.groups.downloads.has(groupId)) {
|
||||
logger.debug("[queueDownloads] group already exists", {
|
||||
groupId
|
||||
});
|
||||
await download.operation;
|
||||
continue;
|
||||
return this.groups.downloads.get(groupId);
|
||||
}
|
||||
|
||||
const { filename, chunkSize } = file;
|
||||
if (await this.exists(filename)) {
|
||||
EV.publish(EVENTS.fileDownloaded, {
|
||||
success: true,
|
||||
groupId,
|
||||
filename,
|
||||
eventData,
|
||||
current,
|
||||
total
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const group = new Set<string>();
|
||||
|
||||
EV.publish(EVENTS.fileDownload, {
|
||||
total,
|
||||
current,
|
||||
groupId,
|
||||
filename
|
||||
});
|
||||
files.forEach((f) => group.add(f.filename));
|
||||
this.groups.downloads.set(groupId, group);
|
||||
|
||||
const url = `${hosts.API_HOST}/s3?name=${filename}`;
|
||||
const { execute, cancel } = this.fs.downloadFile(filename, {
|
||||
url,
|
||||
chunkSize,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
file.cancel = cancel;
|
||||
file.operation = execute()
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
this.downloads.delete(filename);
|
||||
group.delete(filename);
|
||||
});
|
||||
for (const file of files as QueueItem[]) {
|
||||
current++;
|
||||
if (!group.has(file.filename)) {
|
||||
this.eventManager.publish(EVENTS.fileDownloaded, {
|
||||
success: false,
|
||||
groupId,
|
||||
filename: file.filename,
|
||||
eventData,
|
||||
current,
|
||||
total
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
this.downloads.set(filename, file);
|
||||
const result = await file.operation;
|
||||
if (eventData)
|
||||
EV.publish(EVENTS.fileDownloaded, {
|
||||
success: result,
|
||||
const download = this.downloads.get(file.filename);
|
||||
if (download && download.operation) {
|
||||
logger.debug("[queueDownloads] duplicate download", {
|
||||
filename: file.filename,
|
||||
groupId
|
||||
});
|
||||
await download.operation;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { filename, chunkSize } = file;
|
||||
if (await this.exists(filename)) {
|
||||
this.eventManager.publish(EVENTS.fileDownloaded, {
|
||||
success: true,
|
||||
groupId,
|
||||
filename,
|
||||
eventData,
|
||||
current,
|
||||
total
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
this.eventManager.publish(EVENTS.fileDownload, {
|
||||
total,
|
||||
current,
|
||||
groupId,
|
||||
filename,
|
||||
eventData
|
||||
filename
|
||||
});
|
||||
|
||||
const url = `${hosts.API_HOST}/s3?name=${filename}`;
|
||||
const { execute, cancel } = this.fs.downloadFile(filename, {
|
||||
url,
|
||||
chunkSize,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
file.cancel = cancel;
|
||||
file.operation = execute()
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
this.downloads.delete(filename);
|
||||
group.delete(filename);
|
||||
});
|
||||
|
||||
this.downloads.set(filename, file);
|
||||
const result = await file.operation;
|
||||
if (eventData)
|
||||
this.eventManager.publish(EVENTS.fileDownloaded, {
|
||||
success: result,
|
||||
total,
|
||||
current,
|
||||
groupId,
|
||||
filename,
|
||||
eventData
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.groups.downloads.delete(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
async queueUploads(files: DownloadableFile[], groupId: string) {
|
||||
let current = 0;
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
const total = files.length;
|
||||
const group = this.groups.uploads.get(groupId) || new Set();
|
||||
files.forEach((f) => group.add(f.filename));
|
||||
this.groups.uploads.set(groupId, group);
|
||||
try {
|
||||
let current = 0;
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
const total = files.length;
|
||||
|
||||
for (const file of files as QueueItem[]) {
|
||||
if (!group.has(file.filename)) continue;
|
||||
|
||||
const upload = this.uploads.get(file.filename);
|
||||
if (upload && upload.operation) {
|
||||
logger.debug("[queueUploads] duplicate upload", {
|
||||
filename: file.filename,
|
||||
if (this.groups.uploads.has(groupId)) {
|
||||
logger.debug("[queueUploads] group already exists", {
|
||||
groupId
|
||||
});
|
||||
await file.operation;
|
||||
continue;
|
||||
return this.groups.uploads.get(groupId);
|
||||
}
|
||||
|
||||
const { filename, chunkSize } = file;
|
||||
let error = null;
|
||||
const url = `${hosts.API_HOST}/s3?name=${filename}`;
|
||||
const { execute, cancel } = this.fs.uploadFile(filename, {
|
||||
chunkSize,
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
file.cancel = cancel;
|
||||
file.operation = execute()
|
||||
.catch((e) => {
|
||||
logger.error(e, "failed to upload attachment", { hash: filename });
|
||||
error = e;
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
this.uploads.delete(filename);
|
||||
group.delete(filename);
|
||||
const group = new Set<string>();
|
||||
|
||||
files.forEach((f) => group.add(f.filename));
|
||||
this.groups.uploads.set(groupId, group);
|
||||
|
||||
for (const file of files as QueueItem[]) {
|
||||
if (!group.has(file.filename)) continue;
|
||||
|
||||
const upload = this.uploads.get(file.filename);
|
||||
if (upload && upload.operation) {
|
||||
logger.debug("[queueUploads] duplicate upload", {
|
||||
filename: file.filename,
|
||||
groupId
|
||||
});
|
||||
await file.operation;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { filename, chunkSize } = file;
|
||||
let error = null;
|
||||
const url = `${hosts.API_HOST}/s3?name=${filename}`;
|
||||
const { execute, cancel } = this.fs.uploadFile(filename, {
|
||||
chunkSize,
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
file.cancel = cancel;
|
||||
file.operation = execute()
|
||||
.catch((e) => {
|
||||
logger.error(e, "failed to upload attachment", { hash: filename });
|
||||
error = e;
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
this.uploads.delete(filename);
|
||||
group.delete(filename);
|
||||
});
|
||||
|
||||
this.eventManager.publish(EVENTS.fileUpload, {
|
||||
total,
|
||||
current,
|
||||
groupId,
|
||||
filename
|
||||
});
|
||||
|
||||
EV.publish(EVENTS.fileUpload, {
|
||||
total,
|
||||
current,
|
||||
groupId,
|
||||
filename
|
||||
});
|
||||
|
||||
this.uploads.set(filename, file);
|
||||
const result = await file.operation;
|
||||
EV.publish(EVENTS.fileUploaded, {
|
||||
error,
|
||||
success: result,
|
||||
total,
|
||||
current: ++current,
|
||||
groupId,
|
||||
filename
|
||||
});
|
||||
this.uploads.set(filename, file);
|
||||
const result = await file.operation;
|
||||
this.eventManager.publish(EVENTS.fileUploaded, {
|
||||
error,
|
||||
success: result,
|
||||
total,
|
||||
current: ++current,
|
||||
groupId,
|
||||
filename
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.groups.uploads.delete(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,10 +284,16 @@ export class FileStorage {
|
||||
|
||||
if (queue.type === "download") {
|
||||
this.groups.downloads.delete(groupId);
|
||||
EV.publish(EVENTS.downloadCanceled, { groupId, canceled: true });
|
||||
this.eventManager.publish(EVENTS.downloadCanceled, {
|
||||
groupId,
|
||||
canceled: true
|
||||
});
|
||||
} else if (queue.type === "upload") {
|
||||
this.groups.uploads.delete(groupId);
|
||||
EV.publish(EVENTS.uploadCanceled, { groupId, canceled: true });
|
||||
this.eventManager.publish(EVENTS.uploadCanceled, {
|
||||
groupId,
|
||||
canceled: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,6 +326,20 @@ export class FileStorage {
|
||||
});
|
||||
}
|
||||
|
||||
async bulkDeleteFiles(filenames: string[], localOnly = false) {
|
||||
if (filenames.length === 0) return true;
|
||||
|
||||
if (localOnly) return await this.fs.bulkDeleteFiles(filenames);
|
||||
|
||||
const token = await this.tokenManager.getAccessToken();
|
||||
const url = `${hosts.API_HOST}/s3/bulk-delete`;
|
||||
return await this.fs.bulkDeleteFiles(filenames, {
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
chunkSize: 0
|
||||
});
|
||||
}
|
||||
|
||||
exists(filename: string) {
|
||||
return this.fs.exists(filename);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,10 @@ export interface IFileStorage {
|
||||
filename: string,
|
||||
requestOptions?: RequestOptions
|
||||
): Promise<boolean>;
|
||||
bulkDeleteFiles(
|
||||
filenames: string[],
|
||||
requestOptions?: RequestOptions
|
||||
): Promise<boolean>;
|
||||
exists(filename: string): Promise<boolean>;
|
||||
bulkExists(filenames: string[]): Promise<string[]>;
|
||||
getUploadedFileSize(filename: string): Promise<number>;
|
||||
|
||||
@@ -599,6 +599,9 @@ export type User = {
|
||||
attachmentsKey?: Cipher<"base64">;
|
||||
monographPasswordsKey?: Cipher<"base64">;
|
||||
inboxKeys?: { public: string; private: Cipher<"base64"> };
|
||||
dataEncryptionKey?: Cipher<"base64">;
|
||||
legacyDataEncryptionKey?: Cipher<"base64">;
|
||||
|
||||
marketingConsent?: boolean;
|
||||
storageUsed?: number;
|
||||
totalStorage?: number;
|
||||
|
||||
@@ -174,7 +174,7 @@ const Tiptap = ({
|
||||
element: getContentDiv(),
|
||||
editable: !tab.session?.readonly,
|
||||
editorProps: {
|
||||
editable: () => !tab.session?.readonly,
|
||||
editable: () => !tabRef.current.session?.readonly,
|
||||
handlePaste: (view, event) => {
|
||||
const hasFiles = event.clipboardData?.types?.some((type) =>
|
||||
type.startsWith("Files")
|
||||
@@ -602,7 +602,7 @@ const Tiptap = ({
|
||||
|
||||
<Title
|
||||
titlePlaceholder={controller.titlePlaceholder}
|
||||
readonly={settings.readonly}
|
||||
readonly={tab.session?.readonly || settings.readonly || false}
|
||||
controller={controllerRef}
|
||||
title={controller.title}
|
||||
fontFamily={settings.fontFamily}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user