Compare commits

...

91 Commits

Author SHA1 Message Date
Ammar Ahmed
ae751cdd65 mobile: fix readonly title editable 2026-02-27 09:24:00 +05:00
Ammar Ahmed
0d00bb2866 mobile: update @ammarahmed/react-native-upload dep 2026-02-27 08:54:35 +05:00
Ammar Ahmed
4299965064 mobile: update README.md 2026-02-27 08:47:23 +05:00
Abdullah Atta
cf87929476 web: bump version to v3.3.9-beta.2 2026-02-26 12:55:37 +05:00
Ammar Ahmed
d343a0720e mobile: update README.md 2026-02-26 12:46:53 +05:00
Ammar Ahmed
51bd58edb2 mobile: release 3.3.15-beta.1 2026-02-26 12:37:22 +05:00
Ammar Ahmed
f778d83316 editor: prevent audio element request attachment data multiple times 2026-02-26 10:53:32 +05:00
Ammar Ahmed
b169206c68 mobile: fix audio playback in editor 2026-02-26 10:34:28 +05:00
Ammar Ahmed
4cea69e71d core: remove group from queueDownloads when in progress 2026-02-26 10:34:04 +05:00
Ammar Ahmed
04d422b411 core: fix audio attachments not linked to note when attached 2026-02-26 10:32:17 +05:00
Ammar Ahmed
49dec52aa0 mobile: fix multiple unlock with biometrics buttons rendering in vault dialog 2026-02-26 09:44:06 +05:00
Ammar Ahmed
b83a62814d mobile: use home group options when configuring note preview widget 2026-02-26 09:13:34 +05:00
Ammar Ahmed
ce5449f25a intl: fix add tag description 2026-02-26 08:31:02 +05:00
Abdullah Atta
aa769a8921 Merge pull request #9301 from streetwriters/core/orphaned-attachment-cleanup
core: remove orphaned attachments in trash cleanup method
2026-02-25 12:53:17 +05:00
01zulfi
a8d4e6c754 web: fix vault not marked as created when vault is created from locking note (#9394)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-25 11:36:32 +05:00
Abdullah Atta
f2402cf086 web: remove orphaned attachments on app launch 2026-02-25 11:24:31 +05:00
Ammar Ahmed
07a481f002 mobile: remove orphaned attachments on app launch 2026-02-24 12:55:31 +05:00
Ammar Ahmed
f41638db6d mobile: attachment items not rendering on switching list 2026-02-24 12:48:34 +05:00
Ammar Ahmed
82baf41d8e mobile: fix bulk delete call 2026-02-24 12:48:10 +05:00
Abdullah Atta
e60370d4dc editor: improve hr styling (#9377) 2026-02-24 11:08:45 +05:00
01zulfi
3e412323ee web: handle crash when drag-n-drop file in flatpak build (#9379)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-24 11:08:24 +05:00
Abdullah Atta
d635c1a056 Merge pull request #9364 from streetwriters/editor/audio-playback
editor: support audio attachment playback
2026-02-24 11:06:50 +05:00
01zulfi
f02db2a024 editor: fix checklist & blockquote indent so text starts at same pos as other lists (#9358)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-24 11:04:38 +05:00
Abdullah Atta
8c74324ff8 editor: handle attachments based on mime type 2026-02-24 10:29:58 +05:00
01zulfi
f2230eb39b editor: disallow inserting empty embed urls/code (#9392)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-24 09:50:38 +05:00
Abdullah Atta
ab900ae5da editor: various fixes and improvements related to audio widget 2026-02-24 09:36:47 +05:00
Ammar Ahmed
1cf28c56c2 mobile: add bulk delete support in file storage 2026-02-24 09:17:29 +05:00
Ammar Ahmed
08cd33a0cf Merge pull request #9371 from streetwriters/fix-sort-tags-by-note
mobile: sort assigned tags on top when viewing note tags
2026-02-24 08:28:10 +05:00
Ammar Ahmed
7b0d2a239d Merge pull request #9367 from streetwriters/fix/vault-biometrics-ux
Fix vault with biometrics enabled does not lock the note
2026-02-24 08:27:54 +05:00
Abdullah Atta
5d922d934c Merge branch 'master' into beta 2026-02-21 10:36:37 +05:00
Abdullah Atta
5bab9ea914 Merge pull request #9385 from streetwriters/fix/url-crash-in-editor
Fix web/desktop app crashes
2026-02-21 10:34:29 +05:00
Abdullah Atta
da06d7e636 misc: add stack trace deobfuscation script 2026-02-21 10:30:56 +05:00
Abdullah Atta
aece140473 web: fix crash on rewriting error message 2026-02-21 10:30:39 +05:00
Abdullah Atta
656f87e3c6 editor: fix crash if embed url is invalid or empty 2026-02-21 10:30:18 +05:00
Ammar Ahmed
36a95616d9 mobile: cleanup imports 2026-02-20 08:57:49 +05:00
Ammar Ahmed
60bdc4715d mobile: sort assigned tags on top when viewing note tags 2026-02-20 08:50:08 +05:00
Ammar Ahmed
7da168ab52 Merge pull request #9307 from streetwriters/fix-offline-mode-cache-attachments
mobile: fix switching app from background to foreground starts a new attachment cache download group
2026-02-20 08:12:57 +05:00
Ammar Ahmed
b20ad48b31 mobile: release 3.3.15-beta.0 2026-02-20 08:04:53 +05:00
Ammar Ahmed
8b17495f0e mobile: fix vault with biometrics enabled does not lock note 2026-02-19 13:16:09 +05:00
Abdullah Atta
e113e1b59c web: bump version to 3.3.9-beta.1 2026-02-19 11:38:30 +05:00
Abdullah Atta
6bd4924c0b web: add logging about persistence type 2026-02-19 11:37:59 +05:00
Abdullah Atta
a2939aa183 web: use in memory db only for account recovery routes 2026-02-19 11:34:17 +05:00
Abdullah Atta
effa726b65 web: bump version to 3.3.9-beta.0 2026-02-19 10:43:37 +05:00
Abdullah Atta
20d9bf1f23 core: ensure user is fetched after sign up 2026-02-19 10:42:55 +05:00
Abdullah Atta
09e5d014df web: fix page state persistence on account recovery pages 2026-02-19 10:42:55 +05:00
01zulfi
add1b8a68a editor: support audio attachment playback
Co-authored-by: Hamish <133548095+Hamster45105@users.noreply.github.com>
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-18 11:24:45 +05:00
Ammar Ahmed
fe143f66b7 core: remove log 2026-02-16 15:04:05 +05:00
Ammar Ahmed
75c8ca29ef web: publish vaultLocked event from db eventManager 2026-02-16 14:42:43 +05:00
Ammar Ahmed
c52779df23 Merge branch 'master' into beta 2026-02-16 14:40:45 +05:00
Ammar Ahmed
b7b77646e6 Merge pull request #9357 from streetwriters/mobile/release-3.3.14
mobile: release 3.3.14
2026-02-16 14:36:42 +05:00
01zulfi
1157c6b7cd Merge pull request #9351 from streetwriters/web/server-url-from-environment
web: prioritize environment variables for server urls configuration
2026-02-16 14:28:08 +05:00
Abdullah Atta
ab824d7f66 web: fix server urls 2026-02-16 14:27:50 +05:00
Abdullah Atta
702e28a6e6 Merge pull request #9323 from streetwriters/fix/password-changing
Fix account password changing
2026-02-16 14:15:39 +05:00
Ammar Ahmed
0e0ab58959 mobile: release 3.3.14 2026-02-16 13:39:41 +05:00
Ammar Ahmed
e5cdc9ea3d Merge pull request #9355 from streetwriters/fix/use-db-ev-manager
core: use db event manager
2026-02-16 13:34:31 +05:00
Ammar Ahmed
d3c9744e65 core: use db event manager 2026-02-16 13:11:45 +05:00
Ammar Ahmed
6b02144b30 Merge pull request #9354 from streetwriters/fix-loading-note-bug
mobile: loading note gets stuck
2026-02-16 13:07:27 +05:00
Abdullah Atta
91cbc5ddf0 core: simplify user signup 2026-02-16 13:06:58 +05:00
Ammar Ahmed
93aa1ca5aa Merge pull request #9352 from streetwriters/fix-attachment-upload-error
mobile: fix attachment upload error Failed to delete file
2026-02-16 12:59:15 +05:00
Ammar Ahmed
6ae13ebb2f mobile: fix attachment upload error Failed to delete file 2026-02-16 11:43:55 +05:00
Ammar Ahmed
73cdbf64c7 mobile: fix note loading is slow in editor after login/signup 2026-02-13 14:31:13 +05:00
01zulfi
0aeea965c0 core: add support for bulk delete files
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-13 13:01:56 +05:00
Abdullah Atta
a63b15637f core: fix types 2026-02-13 11:33:09 +05:00
Ammar Ahmed
5153b43bc6 mobile: enable password change and forgot password 2026-02-13 11:27:58 +05:00
Ammar Ahmed
9ea7d09722 core: fix imports 2026-02-13 09:45:08 +05:00
01zulfi
b7b474b245 core: fix archived notes not showing in attachment's linked notes (#9001)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-12 14:19:59 +05:00
ash
e2184a8bdf web: fix text not selectable from entire height of editor (#8972)
Signed-off-by: Ash Holland <x-hv2j5m@keysmash.solutions>
2026-02-12 14:05:49 +05:00
01zulfi
9ea84ca9ae editor: reduce horizontal rule height for large screens (#9040)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-12 14:02:01 +05:00
01zulfi
50136e3689 editor: use last set code language as default (#7086)
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-02-12 13:49:52 +05:00
01zulfi
e87f5e5f89 editor: escape html in twitter embed src (#9326)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-10 12:33:56 +05:00
01zulfi
d44792d132 web: align check icon inside note's color in properties panel (#9308)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-09 12:33:28 +05:00
01zulfi
50b01e0b1b web: fix attachment not marked as uploaded when app locked & unlocked (#9306)
When RouteWrapper was unmounted, it would unsub all events including the ones subbed during db initialization (db.init()). Since db isn't initialized again when app is locked & unlocked, those events weren't subbed again. One of these event subscription (fileUploaded event) is responsible for marking an attachment as uploaded. There is no need to unsubscribe all events on RouteWrapper unmount.

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-09 12:32:29 +05:00
Abdullah Atta
122c3a96c7 web: fix account recovery 2026-02-09 12:27:36 +05:00
Abdullah Atta
4d407bd646 web: enable account recovery 2026-02-09 11:10:41 +05:00
Abdullah Atta
16b6b37b94 web: enable password changing 2026-02-09 11:08:00 +05:00
Abdullah Atta
8c766ef4f0 core: add initial DEK support 2026-02-09 11:07:53 +05:00
Ammar Ahmed
b7d2865435 mobile: fix switching app from background to foreground starts
a new attachment cache download group
2026-02-05 13:07:27 +05:00
01zulfi
4a158b2547 core: remove orphaned attachments in trash cleanup method
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-02-04 13:13:56 +05:00
Arjun Vijay Prakash
e381e54bb7 web: fix toolbar item indentation when moving between groups (#8624)
* web: adjust item depth calculation during move operation

Signed-off-by: Arjun Vijay Prakash <arjunv.prakash12345@gmail.com>

* web: add tests for customize-toolbar.tsx

Signed-off-by: Arjun Vijay Prakash <arjunv.prakash12345@gmail.com>

* refactor(toolbar): export types and functions in customize-toolbar.tsx

Signed-off-by: Arjun Vijay Prakash <arjunv.prakash12345@gmail.com>

* refactor(tests): simplify and import types for customize-toolbar tests

Signed-off-by: Arjun Vijay Prakash <arjunv.prakash12345@gmail.com>

* refactor(toolbar): remove export from internal functions in customize-toolbar.tsx

Signed-off-by: Arjun Vijay Prakash <arjunv.prakash12345@gmail.com>

---------

Signed-off-by: Arjun Vijay Prakash <arjunv.prakash12345@gmail.com>
2026-02-03 10:21:46 +05:00
little Kitchen
7001f98353 fix: unable to change zoom level back to 1.0 (#9283)
Fixed two issues with number input settings:

1. Added onBlur handler to immediately commit values when user
   clicks away, bypassing the 500ms debounce delay. This ensures
   values are saved even if the user navigates away quickly.

2. Added key prop based on current value to force re-render
   when the stored value changes, ensuring the input always
   reflects the actual persisted value.

Fixes #4035
2026-02-02 13:05:24 +05:00
Abdullah Atta
a836642d74 Merge pull request #7901 from 01zulfi/web/delete-data-for-not-logged-in-user
web: add delete data option for not logged in user
2026-02-02 12:31:01 +05:00
Abdullah Atta
a3d3add48f Merge pull request #7303 from 01zulfi/web/reminder-dialog-improvements
web: reminder dialogs improvements
2026-02-02 12:30:40 +05:00
01zulfi
994060cce2 editor: allow attaching image as file (#8752)
* editor: allow attaching image as file
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: fix image previews for regular attachments

* mobile: allow images to be added as attachment without preview

* web: minor refactor
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

---------

Co-authored-by: Ammar Ahmed <ammarahmed6506@gmail.com>
2026-02-02 10:26:03 +05:00
Abdullah Atta
75103dc706 Merge pull request #9237 from streetwriters/web/dont-render-properties-on-locked-note
web: don't render properties panel for locked sessions
2026-02-02 09:56:20 +05:00
Abdullah Atta
cf63c2fdc5 Merge branch 'master' into beta 2026-01-28 12:44:31 +05:00
Ammar Ahmed
f15b4cf634 mobile: show note references in reminder page 2026-01-20 10:17:57 +05:00
01zulfi
6430611e3e web: reminder dialogs improvements
* add note references (if any) in reminder edit dialog
* wrap snooze option buttons in reminder preview dialog

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-01-20 09:29:52 +05:00
01zulfi
afe57e41ce web: add confirmation dialog for delete data option
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-01-19 16:18:29 +05:00
Ammar Ahmed
86f3f90143 mobile: delete all data option in settings 2026-01-19 14:24:21 +05:00
01zulfi
d3ea51cc01 web: add delete data option for not logged in user
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-01-19 12:58:48 +05:00
01zulfi
94dbec4486 web: don't render properties panel for locked sessions
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-01-17 14:04:38 +05:00
121 changed files with 3429 additions and 1570 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -183,6 +183,7 @@ const Editor = React.memo(
hideKeyboardAccessoryView={false}
allowsFullscreenVideo={true}
allowFileAccessFromFileURLs={true}
allowsInlineMediaPlayback
allowUniversalAccessFromFileURLs={true}
originWhitelist={["*"]}
source={{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ const Home = ({
/>
<DelayLayout type="settings">
<LegendList
testID="settings-list"
data={settingsGroups}
keyExtractor={keyExtractor}
renderItem={renderItem}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ import { Loading, Refresh } from "../icons";
import { db } from "../../common/db";
import { writeText } from "clipboard-polyfill";
import { showToast } from "../../utils/toast";
import { 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;

View File

@@ -17,6 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 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>
);
},

View File

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

View File

@@ -96,7 +96,8 @@ export const ReminderPreviewDialog = DialogManager.register(
sx={{
alignItems: "center",
my: 1,
gap: 1
gap: 1,
flexWrap: "wrap"
}}
>
{SNOOZE_TIMES.map((time) => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

View 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!

View File

@@ -0,0 +1,3 @@
- Bug fixes and minor improvements
Thank you for using Notesnook!

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Cipher } from "@notesnook/crypto";
import Database from "./index.js";
import { 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;
}

View File

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

View File

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

View File

@@ -121,8 +121,8 @@ export default class Trash {
},
{ noteIds: [] as string[], notebookIds: [] as string[] }
);
await this._delete(noteIds, notebookIds);
await this.buildCache();
}

View File

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

View File

@@ -363,6 +363,7 @@ export class Tiptap {
break;
}
case "iframe":
case "audio":
case "span": {
const hash = attr[ATTRIBUTES.hash];
if (!hash) return;

View File

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

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

View File

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

View File

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

View File

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