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