mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-31 11:08:47 +02:00
Compare commits
151 Commits
v3.3.16
...
release/3.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4368a08e | ||
|
|
537c514562 | ||
|
|
d9720ba7b8 | ||
|
|
16e968dc57 | ||
|
|
03fafcbd15 | ||
|
|
b7bd06c9f3 | ||
|
|
766a8f28d4 | ||
|
|
86512b5ce7 | ||
|
|
a820fcde53 | ||
|
|
5ddf1eddf5 | ||
|
|
4237d2eb4a | ||
|
|
f67cd503d6 | ||
|
|
14039d6a1e | ||
|
|
d1fed9072e | ||
|
|
b003a3ffed | ||
|
|
55e10e4755 | ||
|
|
7c1dd0f79a | ||
|
|
229cf195a8 | ||
|
|
38da6ac08b | ||
|
|
597dc587cd | ||
|
|
5c76b92789 | ||
|
|
8418d5fa9c | ||
|
|
cd071f8f58 | ||
|
|
32ddc3fa40 | ||
|
|
68d07a7862 | ||
|
|
2aa5162fc9 | ||
|
|
cf5565d09d | ||
|
|
f129e0c411 | ||
|
|
bb43d80e05 | ||
|
|
4e4d3866df | ||
|
|
b2088ef782 | ||
|
|
07db14f6bf | ||
|
|
82d37701c5 | ||
|
|
b48ab3c85d | ||
|
|
52d7d90b68 | ||
|
|
bb7ff4f376 | ||
|
|
43b35917ef | ||
|
|
bcfa34ca05 | ||
|
|
aab6a36067 | ||
|
|
c7e0cd35e7 | ||
|
|
bd3edc535a | ||
|
|
1dff7a121d | ||
|
|
3f3467e3a7 | ||
|
|
39bb9afa0e | ||
|
|
886e757715 | ||
|
|
f1124c4112 | ||
|
|
a1575ab5ea | ||
|
|
d31eaef6d4 | ||
|
|
930e31d109 | ||
|
|
2a6cc90592 | ||
|
|
a13962f8c9 | ||
|
|
5c93645969 | ||
|
|
a4c8259a02 | ||
|
|
07defdfa78 | ||
|
|
fc93a39164 | ||
|
|
9b588c50bf | ||
|
|
27b00d4ef9 | ||
|
|
91e0437554 | ||
|
|
4dcf14fce1 | ||
|
|
8f8ac25d20 | ||
|
|
b594d58d31 | ||
|
|
20921df3f1 | ||
|
|
43d3036525 | ||
|
|
f725499039 | ||
|
|
cd743a92cb | ||
|
|
ea0550cb05 | ||
|
|
d9d5cb2168 | ||
|
|
69e509da1c | ||
|
|
6ba1fda2f2 | ||
|
|
93efecd6c3 | ||
|
|
70d1119cf0 | ||
|
|
3f11b76811 | ||
|
|
d912c93f81 | ||
|
|
e635bf628a | ||
|
|
8048c90a89 | ||
|
|
8093f44a70 | ||
|
|
d0bfc21b49 | ||
|
|
f67f90cecd | ||
|
|
ff4e2631cb | ||
|
|
9779685102 | ||
|
|
f2925725b8 | ||
|
|
9ca9fe3f99 | ||
|
|
8e5039fa7f | ||
|
|
2f47fe331d | ||
|
|
238abee126 | ||
|
|
a8d0fa3b94 | ||
|
|
27b3cfcd23 | ||
|
|
d3f1690dca | ||
|
|
d4d7eac240 | ||
|
|
e7fb246c25 | ||
|
|
03defb3a48 | ||
|
|
73c44d476b | ||
|
|
61aaaba452 | ||
|
|
9992bf8c32 | ||
|
|
b8b7d37799 | ||
|
|
0aaabfab62 | ||
|
|
14d680c092 | ||
|
|
28f95b0061 | ||
|
|
419dd555f4 | ||
|
|
1312ce7d50 | ||
|
|
d16cd510b3 | ||
|
|
af08ed5174 | ||
|
|
365e1d7029 | ||
|
|
80b75fc28c | ||
|
|
6de4b4ac2d | ||
|
|
39bfff6b14 | ||
|
|
e381b62e5c | ||
|
|
ffef5affce | ||
|
|
780276b30e | ||
|
|
e023f11cd9 | ||
|
|
276239dd2b | ||
|
|
5ef3bdcad4 | ||
|
|
63076d37f5 | ||
|
|
724ad58824 | ||
|
|
7fcdb3b55d | ||
|
|
47ba40d7cd | ||
|
|
0801e73242 | ||
|
|
c82487983c | ||
|
|
2ad39048da | ||
|
|
3421627f31 | ||
|
|
cf6262e995 | ||
|
|
8a325bf7c0 | ||
|
|
883667b411 | ||
|
|
06b88e74ae | ||
|
|
7800a25775 | ||
|
|
01d0de3909 | ||
|
|
d61f9472c8 | ||
|
|
2465337148 | ||
|
|
af4638ac50 | ||
|
|
5e6be09aab | ||
|
|
f626953e0a | ||
|
|
4d4bce301d | ||
|
|
d45b18afd6 | ||
|
|
4f212453a6 | ||
|
|
2c1b9081bf | ||
|
|
7c5dc1615c | ||
|
|
3fb07369bb | ||
|
|
9b480950da | ||
|
|
9f20d243fa | ||
|
|
13e6573342 | ||
|
|
f37869cb5a | ||
|
|
46d802548b | ||
|
|
2bf42c207d | ||
|
|
65e42e5d8b | ||
|
|
c85007f0dd | ||
|
|
36058145b5 | ||
|
|
a376a6b497 | ||
|
|
cd62d51e45 | ||
|
|
3f6fcbacbc | ||
|
|
2a2bf67fea | ||
|
|
04f6e1068b |
5
.github/workflows/ios.preview.firebase.yml
vendored
5
.github/workflows/ios.preview.firebase.yml
vendored
@@ -38,6 +38,11 @@ jobs:
|
||||
with:
|
||||
xcode-version: "26.1.1"
|
||||
|
||||
- name: Setup iOS Platform
|
||||
run: |
|
||||
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
|
||||
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
|
||||
|
||||
- name: Install node modules
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
|
||||
6
.github/workflows/ios.publish.yml
vendored
6
.github/workflows/ios.publish.yml
vendored
@@ -19,6 +19,11 @@ jobs:
|
||||
with:
|
||||
xcode-version: "26.1.1"
|
||||
|
||||
- name: Setup iOS Platform
|
||||
run: |
|
||||
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
|
||||
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
|
||||
|
||||
- name: Install node modules
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
@@ -111,6 +116,7 @@ jobs:
|
||||
api-private-key: ${{ secrets.API_KEY }}
|
||||
|
||||
- name: Upload Notesnook.ipa to Github
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Notesnook.zip
|
||||
|
||||
4
apps/desktop/package-lock.json
generated
4
apps/desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.16",
|
||||
"version": "3.3.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.16",
|
||||
"version": "3.3.23",
|
||||
"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.16",
|
||||
"version": "3.3.23",
|
||||
"appAppleId": "1544027013",
|
||||
"private": true,
|
||||
"main": "./dist/cjs/index.js",
|
||||
|
||||
@@ -33,16 +33,17 @@ import { AutoLaunch } from "../utils/autolaunch";
|
||||
import { config, DesktopIntegration } from "../utils/config";
|
||||
import { bringToFront } from "../utils/bring-to-front";
|
||||
import { getTheme, setTheme, Theme } from "../utils/theme";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import { resolvePath } from "../utils/resolve-path";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { AssetManager } from "../utils/asset-manager";
|
||||
import { isFlatpak, isSnap } from "../utils";
|
||||
import { isFlatpak, isPortable, isSnap } from "../utils";
|
||||
import { setupDesktopIntegration } from "../utils/desktop-integration";
|
||||
import { rm } from "fs/promises";
|
||||
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
|
||||
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
|
||||
import { platform } from "os";
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
@@ -60,6 +61,7 @@ const NotificationOptions = z.object({
|
||||
export const osIntegrationRouter = t.router({
|
||||
isFlatpak: t.procedure.query(() => isFlatpak()),
|
||||
isSnap: t.procedure.query(() => isSnap()),
|
||||
isPortable: t.procedure.query(() => isPortable()),
|
||||
|
||||
zoomFactor: t.procedure.query(() => config.zoomFactor),
|
||||
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
|
||||
@@ -191,9 +193,28 @@ export const osIntegrationRouter = t.router({
|
||||
}),
|
||||
openPath: t.procedure
|
||||
.input(z.object({ type: z.literal("path"), link: z.string() }))
|
||||
.query(({ input }) => {
|
||||
.query(async ({ input }) => {
|
||||
if (isFlatpak()) return;
|
||||
|
||||
const { type, link } = input;
|
||||
if (type === "path") return shell.openPath(resolvePath(link));
|
||||
if (type !== "path") return;
|
||||
|
||||
const resolvedPath = resolvePath(
|
||||
// remove leading slash from path on windows
|
||||
platform() === "win32" ? link.slice(1) : link
|
||||
);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
if (globalThis.window) {
|
||||
await dialog.showMessageBox(globalThis.window, {
|
||||
type: "error",
|
||||
title: "Path not found",
|
||||
message: `The path does not exist:\n${wrapPath(resolvedPath)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await shell.openPath(resolvedPath);
|
||||
}),
|
||||
bringToFront: t.procedure.query(() => bringToFront()),
|
||||
changeTheme: t.procedure
|
||||
@@ -298,3 +319,7 @@ function toMenuItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapPath(path: string, maxLineLength = 100): string {
|
||||
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
|
||||
}
|
||||
|
||||
@@ -84,35 +84,55 @@ const LANGUAGES: Record<string, string> = {
|
||||
|
||||
type Language = { code: string; name: string };
|
||||
|
||||
const LANGUAGE_REDIRECT_MAP: Record<string, string> = {
|
||||
es: "es-MX",
|
||||
"es-419": "es-MX",
|
||||
"es-ES": "es-AR"
|
||||
};
|
||||
|
||||
export const spellCheckerRouter = t.router({
|
||||
isEnabled: t.procedure.query(() => config.isSpellCheckerEnabled),
|
||||
languages: t.procedure.query(
|
||||
() =>
|
||||
<Language[]>(
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages.map(
|
||||
(code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code]
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
enabledLanguages: t.procedure.query(
|
||||
() =>
|
||||
<Language[]>(
|
||||
globalThis.window?.webContents.session
|
||||
.getSpellCheckerLanguages()
|
||||
.map((code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code]
|
||||
}))
|
||||
)
|
||||
),
|
||||
setLanguages: t.procedure
|
||||
.input(z.array(z.string()))
|
||||
.mutation(({ input: languages }) =>
|
||||
globalThis.window?.webContents.session.setSpellCheckerLanguages(languages)
|
||||
),
|
||||
languages: t.procedure.query(() => {
|
||||
const available =
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||
[];
|
||||
|
||||
return <Language[]>available
|
||||
.map((code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code] || code
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}),
|
||||
|
||||
enabledLanguages: t.procedure.query(() => {
|
||||
const enabled =
|
||||
globalThis.window?.webContents.session.getSpellCheckerLanguages() || [];
|
||||
const available =
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||
[];
|
||||
|
||||
const resolved = enabled
|
||||
.map((code) => resolveLanguage(code, available))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
return <Language[]>resolved.map((code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code] || code
|
||||
}));
|
||||
}),
|
||||
|
||||
setLanguages: t.procedure.input(z.array(z.string())).mutation(({ input }) => {
|
||||
const available =
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||
[];
|
||||
|
||||
const resolved = input
|
||||
.map((code) => resolveLanguage(code, available))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
globalThis.window?.webContents.session.setSpellCheckerLanguages(resolved);
|
||||
}),
|
||||
toggle: t.procedure
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.mutation(({ input: { enabled } }) => {
|
||||
@@ -128,3 +148,16 @@ export const spellCheckerRouter = t.router({
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
function resolveLanguage(code: string, available: string[]) {
|
||||
if (LANGUAGE_REDIRECT_MAP[code]) {
|
||||
const working = LANGUAGE_REDIRECT_MAP[code];
|
||||
return available.includes(working) ? working : code;
|
||||
}
|
||||
const fallback = code.split("-")[0];
|
||||
return available.includes(code)
|
||||
? code
|
||||
: available.includes(fallback)
|
||||
? fallback
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { AppUpdaterEvents } from "electron-updater/out/AppUpdater";
|
||||
import { z } from "zod";
|
||||
import { config } from "../utils/config";
|
||||
import { app } from "electron";
|
||||
import { isFlatpak, isPortable, isSnap } from "../utils";
|
||||
|
||||
type UpdateInfo = { version: string };
|
||||
type Progress = { percent: number };
|
||||
@@ -31,13 +32,15 @@ type Progress = { percent: number };
|
||||
const t = initTRPC.create();
|
||||
let cancellationToken: CancellationToken | undefined = undefined;
|
||||
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const updatesSupported = !isFlatpak() && !isSnap() && !isPortable();
|
||||
export const updaterRouter = t.router({
|
||||
autoUpdates: t.procedure.query(() => config.automaticUpdates),
|
||||
autoUpdates: t.procedure.query(
|
||||
() => updatesSupported && config.automaticUpdates
|
||||
),
|
||||
releaseTrack: t.procedure.query(() => config.releaseTrack),
|
||||
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
|
||||
download: t.procedure.query(async () => {
|
||||
if (cancellationToken) return;
|
||||
if (!updatesSupported || cancellationToken) return;
|
||||
clearTimeout(downloadTimeout);
|
||||
await new Promise<string[]>((resolve, reject) => {
|
||||
downloadTimeout = setTimeout(async () => {
|
||||
@@ -52,7 +55,7 @@ export const updaterRouter = t.router({
|
||||
});
|
||||
}),
|
||||
check: t.procedure.query(async () => {
|
||||
if (cancellationToken) return;
|
||||
if (!updatesSupported || cancellationToken) return;
|
||||
clearTimeout(downloadTimeout);
|
||||
await new Promise<void>((resolve) => {
|
||||
downloadTimeout = setTimeout(async () => {
|
||||
|
||||
@@ -33,3 +33,7 @@ export function isFlatpak() {
|
||||
export function isSnap() {
|
||||
return process.env.SNAP !== undefined;
|
||||
}
|
||||
|
||||
export function isPortable() {
|
||||
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ android {
|
||||
if (project.hasProperty("prBuildNumber")) {
|
||||
versionCode Integer.parseInt(prBuildNumber())
|
||||
} else {
|
||||
versionCode 3100
|
||||
versionCode 3106
|
||||
}
|
||||
versionName getNpmVersion()
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
|
||||
@@ -66,6 +66,9 @@ const App = (props: { configureMode: "note-preview" }) => {
|
||||
useEffect(() => {
|
||||
SettingsService.onFirstLaunch();
|
||||
changeSystemBarColors();
|
||||
SettingsService.setPrivacyScreen(
|
||||
SettingsService.getProperty("privacyScreen")
|
||||
);
|
||||
setTimeout(async () => {
|
||||
await Notifications.get();
|
||||
if (SettingsService.get().notifNotes) {
|
||||
|
||||
@@ -115,6 +115,10 @@ export const FileSizeResult = {
|
||||
Error: -1
|
||||
};
|
||||
|
||||
function getFileSizeFromHeaders(headers: Headers) {
|
||||
return headers.get("x-object-size") || headers.get("content-length");
|
||||
}
|
||||
|
||||
export async function getUploadedFileSize(hash: string, retry = 0) {
|
||||
try {
|
||||
const url = `${hosts.API_HOST}/s3?name=${hash}`;
|
||||
@@ -124,24 +128,21 @@ export async function getUploadedFileSize(hash: string, retry = 0) {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (
|
||||
!attachmentInfo.ok ||
|
||||
attachmentInfo.headers?.get("content-length") === null
|
||||
) {
|
||||
const fileSize = getFileSizeFromHeaders(attachmentInfo.headers);
|
||||
|
||||
if (!attachmentInfo.ok || fileSize === null) {
|
||||
if (retry < 3) {
|
||||
DatabaseLogger.log(`Retrying file size check: ${hash}, ${retry}`);
|
||||
return getUploadedFileSize(hash, retry + 1);
|
||||
}
|
||||
throw new Error(
|
||||
`File size check failed: ${hash}, ${
|
||||
attachmentInfo.status
|
||||
}, ${attachmentInfo.headers?.get("content-length")}`
|
||||
`File size check failed: ${hash}, ${attachmentInfo.status}, ${fileSize}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = parseInt(
|
||||
attachmentInfo.headers?.get("content-length") as string
|
||||
);
|
||||
console.log(attachmentInfo.headers);
|
||||
|
||||
const contentLength = parseInt(fileSize as string);
|
||||
return isNaN(contentLength) ? FileSizeResult.Empty : contentLength;
|
||||
} catch (e) {
|
||||
DatabaseLogger.error(e);
|
||||
@@ -161,10 +162,10 @@ export async function checkUpload(
|
||||
size === 0
|
||||
? `File size is 0.`
|
||||
: size === -1
|
||||
? `File verification check failed.`
|
||||
: expectedSize !== decryptedLength
|
||||
? `File size mismatch. Expected ${size} bytes but got ${decryptedLength} bytes.`
|
||||
: undefined;
|
||||
? `File verification check failed.`
|
||||
: expectedSize !== decryptedLength
|
||||
? `File size mismatch. Expected ${size} bytes but got ${decryptedLength} bytes.`
|
||||
: undefined;
|
||||
if (error) throw new Error(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import React, { RefObject, useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { db } from "../../common/database";
|
||||
@@ -59,6 +59,7 @@ import Paragraph from "../ui/typography/paragraph";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import Navigation from "../../services/navigation";
|
||||
import { createFormRef, validators } from "../ui/input/form-input";
|
||||
|
||||
const Actions = ({
|
||||
attachment,
|
||||
@@ -153,50 +154,100 @@ const Actions = ({
|
||||
{
|
||||
name: strings.rename(),
|
||||
onPress: () => {
|
||||
presentDialog({
|
||||
input: true,
|
||||
title: strings.renameFile(),
|
||||
defaultValue: attachment.filename,
|
||||
positivePress: async (value) => {
|
||||
if (value && value.length > 0) {
|
||||
await db.attachments.add({
|
||||
hash: attachment.hash,
|
||||
filename: value
|
||||
});
|
||||
setFilename(value);
|
||||
setAttachments();
|
||||
eSendEvent(eDBItemUpdate, attachment.id);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
positiveText: strings.rename()
|
||||
});
|
||||
close?.();
|
||||
setTimeout(() => {
|
||||
presentDialog({
|
||||
title: strings.renameFile(),
|
||||
form: {
|
||||
formRef: createFormRef({
|
||||
name: attachment.filename
|
||||
}),
|
||||
items: [
|
||||
{
|
||||
name: "name",
|
||||
defaultValue: attachment.filename,
|
||||
placeholder: strings.enterTitle(),
|
||||
ref: React.createRef<TextInput | null>(),
|
||||
validators: [validators.required(strings.nameIsRequired())]
|
||||
}
|
||||
],
|
||||
onFormSubmit: async (form) => {
|
||||
try {
|
||||
const value = form.getValue("name");
|
||||
await db.attachments.add({
|
||||
hash: attachment.hash,
|
||||
filename: value
|
||||
});
|
||||
setFilename(value);
|
||||
setAttachments();
|
||||
eSendEvent(eDBItemUpdate, attachment.id);
|
||||
ToastManager.show({
|
||||
message: `Attachment renamed to ${value}`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
form.setError("name", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
positiveText: strings.rename()
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
icon: "form-textbox"
|
||||
},
|
||||
{
|
||||
name: strings.delete(),
|
||||
onPress: async () => {
|
||||
const relations = await db.relations.to(attachment, "note").get();
|
||||
await db.attachments.remove(attachment.hash, false);
|
||||
setAttachments();
|
||||
eSendEvent(eDBItemUpdate, attachment.id);
|
||||
relations
|
||||
.map((relation) => relation.fromId)
|
||||
.forEach(async (id) => {
|
||||
useTabStore.getState().forEachNoteTab(id, async (tab) => {
|
||||
const isFocused = useTabStore.getState().currentTab === tab.id;
|
||||
if (isFocused) {
|
||||
eSendEvent(eOnLoadNote, {
|
||||
item: await db.notes.note(id),
|
||||
forced: true
|
||||
});
|
||||
} else {
|
||||
editorController.current.commands.setLoading(true, tab.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
close?.();
|
||||
setTimeout(() => {
|
||||
presentDialog({
|
||||
title: strings.deleteAttachment(),
|
||||
paragraph: strings.deleteAttachmentConfirm(),
|
||||
positiveText: strings.yes(),
|
||||
negativeText: strings.no(),
|
||||
positiveType: "errorShade",
|
||||
positivePress: async () => {
|
||||
try {
|
||||
const relations = await db.relations
|
||||
.to(attachment, "note")
|
||||
.get();
|
||||
await db.attachments.remove(attachment.hash, false);
|
||||
ToastManager.show({
|
||||
type: "success",
|
||||
message: strings.attachmentDeleted()
|
||||
});
|
||||
setAttachments();
|
||||
eSendEvent(eDBItemUpdate, attachment.id);
|
||||
relations
|
||||
.map((relation) => relation.fromId)
|
||||
.forEach(async (id) => {
|
||||
useTabStore.getState().forEachNoteTab(id, async (tab) => {
|
||||
const isFocused =
|
||||
useTabStore.getState().currentTab === tab.id;
|
||||
if (isFocused) {
|
||||
eSendEvent(eOnLoadNote, {
|
||||
item: await db.notes.note(id),
|
||||
forced: true
|
||||
});
|
||||
} else {
|
||||
editorController.current.commands.setLoading(
|
||||
true,
|
||||
tab.id
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
icon: "delete-outline"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { db } from "../../common/database";
|
||||
@@ -26,42 +27,44 @@ import { eSendEvent, ToastManager } from "../../services/event-manager";
|
||||
import Navigation from "../../services/navigation";
|
||||
import { useUserStore } from "../../stores/use-user-store";
|
||||
import { eOpenRecoveryKeyDialog } from "../../utils/events";
|
||||
import { AppFontSize } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { Dialog } from "../dialog";
|
||||
import AppIcon from "../ui/AppIcon";
|
||||
import { Button } from "../ui/button";
|
||||
import Input from "../ui/input";
|
||||
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
|
||||
import { Notice } from "../ui/notice";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { TextInput } from "react-native-gesture-handler";
|
||||
|
||||
export const ChangePassword = () => {
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const password = useRef<string>(undefined);
|
||||
const { colors } = useThemeColors();
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
oldPassword: "",
|
||||
password: ""
|
||||
})
|
||||
);
|
||||
const oldPasswordInputRef = useRef<TextInput>(null);
|
||||
const oldPassword = useRef<string>(undefined);
|
||||
|
||||
const [error, setError] = useState(false);
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const user = useUserStore((state) => state.user);
|
||||
|
||||
const changePassword = async () => {
|
||||
setError(undefined);
|
||||
formRef.current.clearErrors();
|
||||
|
||||
if (!user?.isEmailConfirmed) {
|
||||
ToastManager.show({
|
||||
heading: strings.emailNotConfirmed(),
|
||||
message: strings.emailNotConfirmedDesc(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setError(strings.emailNotConfirmedDesc());
|
||||
return;
|
||||
}
|
||||
if (error || !oldPassword.current || !password.current) {
|
||||
ToastManager.show({
|
||||
heading: strings.allFieldsRequired(),
|
||||
message: strings.allFieldsRequiredDesc(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
if (!formRef.current.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = formRef.current.getValues();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await BackupService.run(
|
||||
@@ -74,8 +77,8 @@ export const ChangePassword = () => {
|
||||
}
|
||||
|
||||
const passwordChanged = await db.user.changePassword(
|
||||
oldPassword.current,
|
||||
password.current
|
||||
values.oldPassword,
|
||||
values.password
|
||||
);
|
||||
|
||||
if (!passwordChanged) {
|
||||
@@ -91,15 +94,15 @@ export const ChangePassword = () => {
|
||||
Navigation.goBack();
|
||||
eSendEvent(eOpenRecoveryKeyDialog);
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: strings.passwordChangeFailed(),
|
||||
message: (e as Error).message,
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
|
||||
if (/old password/i.test(message)) {
|
||||
formRef.current.setError("oldPassword", message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -110,36 +113,61 @@ export const ChangePassword = () => {
|
||||
}}
|
||||
>
|
||||
<Dialog context="change-password-dialog" />
|
||||
<Input
|
||||
<FormInput
|
||||
name="oldPassword"
|
||||
formRef={formRef}
|
||||
fwdRef={oldPasswordInputRef}
|
||||
onChangeText={(value) => {
|
||||
oldPassword.current = value;
|
||||
}}
|
||||
loading={loading}
|
||||
validators={[validators.required(strings.currentPasswordRequired())]}
|
||||
returnKeyLabel="Next"
|
||||
returnKeyType="next"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder={strings.oldPassword()}
|
||||
placeholder={strings.currentPassword()}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={passwordInputRef}
|
||||
onChangeText={(value) => {
|
||||
password.current = value;
|
||||
}}
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
loading={loading}
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
returnKeyLabel={strings.next()}
|
||||
returnKeyType="next"
|
||||
secureTextEntry
|
||||
validationType="password"
|
||||
autoComplete="password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder={strings.newPassword()}
|
||||
onSubmitEditing={() => {
|
||||
changePassword();
|
||||
}}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Paragraph
|
||||
numberOfLines={4}
|
||||
onPress={() => {}}
|
||||
color={colors.error.accent}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{error}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
|
||||
<Notice text={strings.changePasswordNotice()} type="alert" />
|
||||
|
||||
<View style={{ height: 10 }} />
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useThemeColors } from "@notesnook/theme";
|
||||
import DialogHeader from "../dialog/dialog-header";
|
||||
import { Button } from "../ui/button";
|
||||
import { IconButton } from "../ui/icon-button";
|
||||
import Input from "../ui/input";
|
||||
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
|
||||
import Seperator from "../ui/seperator";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
@@ -36,21 +36,22 @@ import { DefaultAppStyles } from "../../utils/styles";
|
||||
|
||||
export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
|
||||
const { colors } = useThemeColors("sheet");
|
||||
const email = useRef<string>(userEmail);
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
email: userEmail || ""
|
||||
})
|
||||
);
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const sendRecoveryEmail = async () => {
|
||||
if (!email.current || error) {
|
||||
ToastManager.show({
|
||||
heading: strings.emailRequired(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
if (formRef.current.validateField("email")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = formRef.current.getValues();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const lastRecoveryEmailTime = SettingsService.get().lastRecoveryEmailTime;
|
||||
@@ -60,7 +61,7 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
|
||||
) {
|
||||
throw new Error(strings.pleaseWaitBeforeSendEmail());
|
||||
}
|
||||
await db.user.recoverAccount(email.current.toLowerCase());
|
||||
await db.user.recoverAccount(values.email.toLowerCase());
|
||||
SettingsService.set({
|
||||
lastRecoveryEmailTime: Date.now()
|
||||
});
|
||||
@@ -75,12 +76,7 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
|
||||
setSent(true);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: strings.recoveryEmailFailed(),
|
||||
message: (e as Error).message,
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
formRef.current.setError("email", (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,22 +122,25 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
|
||||
<DialogHeader title={strings.accountRecovery()} />
|
||||
<Seperator />
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="email"
|
||||
formRef={formRef}
|
||||
fwdRef={emailInputRef}
|
||||
onChangeText={(value) => {
|
||||
email.current = value;
|
||||
}}
|
||||
defaultValue={email.current}
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
loading={loading}
|
||||
returnKeyLabel={strings.next()}
|
||||
returnKeyType="next"
|
||||
autoComplete="email"
|
||||
validationType="email"
|
||||
keyboardType="email-address"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
errorMessage={strings.emailInvalid()}
|
||||
placeholder={strings.email()}
|
||||
onSubmit={() => {}}
|
||||
validators={[
|
||||
validators.required(strings.emailRequired()),
|
||||
validators.email(strings.enterAValidEmailAddress())
|
||||
]}
|
||||
onSubmitEditing={() => {
|
||||
sendRecoveryEmail();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -37,8 +37,9 @@ import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { sleep } from "../../utils/time";
|
||||
import { Dialog } from "../dialog";
|
||||
import { Progress } from "../sheets/progress";
|
||||
import AppIcon from "../ui/AppIcon";
|
||||
import { Button } from "../ui/button";
|
||||
import Input from "../ui/input";
|
||||
import FormInput, { validators } from "../ui/input/form-input";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { hideAuth } from "./common";
|
||||
@@ -63,14 +64,13 @@ export const Login = ({
|
||||
const {
|
||||
step,
|
||||
setStep,
|
||||
password,
|
||||
email,
|
||||
emailInputRef,
|
||||
passwordInputRef,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
login
|
||||
login,
|
||||
error,
|
||||
formRef
|
||||
} = useLogin(async () => {
|
||||
eSendEvent(eUserLoggedIn, true);
|
||||
await sleep(500);
|
||||
@@ -93,6 +93,11 @@ export const Login = ({
|
||||
});
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isTablet = width > 600;
|
||||
|
||||
const onContinue = () => {
|
||||
login();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async () => {
|
||||
setStep(LoginSteps.emailAuth);
|
||||
@@ -198,27 +203,27 @@ export const Login = ({
|
||||
gap: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
<FormInput
|
||||
name="email"
|
||||
formRef={formRef}
|
||||
fwdRef={emailInputRef}
|
||||
onChangeText={(value) => {
|
||||
email.current = value;
|
||||
}}
|
||||
testID="input.email"
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
returnKeyLabel="Next"
|
||||
returnKeyType="next"
|
||||
autoComplete="email"
|
||||
validationType="email"
|
||||
keyboardType="email-address"
|
||||
marginBottom={0}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
errorMessage={strings.emailInvalid()}
|
||||
placeholder={strings.email()}
|
||||
defaultValue={email.current}
|
||||
editable={step === LoginSteps.emailAuth && !loading}
|
||||
onSubmit={() => {
|
||||
validators={[
|
||||
validators.required(strings.emailRequired()),
|
||||
validators.email(strings.enterAValidEmailAddress())
|
||||
]}
|
||||
onSubmitEditing={() => {
|
||||
if (step === LoginSteps.emailAuth) {
|
||||
login();
|
||||
onContinue();
|
||||
} else {
|
||||
passwordInputRef.current?.focus();
|
||||
}
|
||||
@@ -227,11 +232,10 @@ export const Login = ({
|
||||
|
||||
{step === LoginSteps.passwordAuth && (
|
||||
<>
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={passwordInputRef}
|
||||
onChangeText={(value) => {
|
||||
password.current = value;
|
||||
}}
|
||||
testID="input.password"
|
||||
returnKeyLabel={strings.done()}
|
||||
returnKeyType="done"
|
||||
@@ -242,9 +246,9 @@ export const Login = ({
|
||||
placeholder={strings.password()}
|
||||
marginBottom={0}
|
||||
editable={!loading}
|
||||
defaultValue={password.current}
|
||||
onSubmit={() => {
|
||||
login();
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
onSubmitEditing={() => {
|
||||
onContinue();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -255,9 +259,13 @@ export const Login = ({
|
||||
paddingHorizontal: 0
|
||||
}}
|
||||
onPress={() => {
|
||||
if (loading || !email.current) return;
|
||||
if (loading) return;
|
||||
presentSheet({
|
||||
component: <ForgotPassword userEmail={email.current} />
|
||||
component: (
|
||||
<ForgotPassword
|
||||
userEmail={formRef.current.getValue("email")}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}}
|
||||
textStyle={{
|
||||
@@ -273,8 +281,7 @@ export const Login = ({
|
||||
<Button
|
||||
loading={loading}
|
||||
onPress={() => {
|
||||
if (loading) return;
|
||||
login();
|
||||
onContinue();
|
||||
}}
|
||||
style={{
|
||||
width: "100%"
|
||||
@@ -328,6 +335,25 @@ export const Login = ({
|
||||
</Paragraph>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Paragraph
|
||||
numberOfLines={4}
|
||||
onPress={() => {}}
|
||||
color={colors.error.accent}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{error.message}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -43,29 +43,26 @@ import SheetProvider from "../sheet-provider";
|
||||
import { Toast } from "../toast";
|
||||
import { Button } from "../ui/button";
|
||||
import { IconButton } from "../ui/icon-button";
|
||||
import Input from "../ui/input";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { LoginSteps, useLogin } from "./use-login";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { getObfuscatedEmail } from "../../utils/functions";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import FormInput, { validators } from "../ui/input/form-input";
|
||||
|
||||
export const SessionExpired = () => {
|
||||
const { colors } = useThemeColors();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const { step, password, email, passwordInputRef, loading, login } = useLogin(
|
||||
() => {
|
||||
eSendEvent(eUserLoggedIn, true);
|
||||
setVisible(false);
|
||||
setFocused(false);
|
||||
useUserStore.setState({
|
||||
disableAppLockRequests: false
|
||||
});
|
||||
},
|
||||
true
|
||||
);
|
||||
const { step, passwordInputRef, loading, login, formRef } = useLogin(() => {
|
||||
eSendEvent(eUserLoggedIn, true);
|
||||
setVisible(false);
|
||||
setFocused(false);
|
||||
useUserStore.setState({
|
||||
disableAppLockRequests: false
|
||||
});
|
||||
}, true);
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -104,7 +101,7 @@ export const SessionExpired = () => {
|
||||
if (!complete) {
|
||||
const user = await db.user.getUser();
|
||||
if (!user) return;
|
||||
email.current = user.email;
|
||||
formRef.current.setValue("email", user.email);
|
||||
setVisible(true);
|
||||
setFocused(false);
|
||||
return;
|
||||
@@ -117,14 +114,14 @@ export const SessionExpired = () => {
|
||||
} catch (e) {
|
||||
const user = await db.user.getUser();
|
||||
if (!user) return;
|
||||
email.current = user.email;
|
||||
formRef.current.setValue("email", user.email);
|
||||
setFocused(false);
|
||||
setVisible(true);
|
||||
useUserStore.setState({
|
||||
disableAppLockRequests: true
|
||||
});
|
||||
}
|
||||
}, [email]);
|
||||
}, [formRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = eSubscribeEvent(eLoginSessionExpired, open);
|
||||
@@ -155,6 +152,7 @@ export const SessionExpired = () => {
|
||||
enableSheetKeyboardHandler={true}
|
||||
visible={true}
|
||||
>
|
||||
<Dialog context="two_factor_verify" />
|
||||
<View
|
||||
style={{
|
||||
width: focused ? "100%" : "99.9%",
|
||||
@@ -192,17 +190,17 @@ export const SessionExpired = () => {
|
||||
}}
|
||||
>
|
||||
{strings.sessionExpiredDesc(
|
||||
getObfuscatedEmail(email.current as string)
|
||||
getObfuscatedEmail(formRef.current.getValue("email") as string)
|
||||
)}
|
||||
</Paragraph>
|
||||
</View>
|
||||
|
||||
{step === LoginSteps.passwordAuth ? (
|
||||
<Input
|
||||
<FormInput
|
||||
fwdRef={passwordInputRef}
|
||||
onChangeText={(value) => {
|
||||
password.current = value;
|
||||
}}
|
||||
formRef={formRef}
|
||||
name="password"
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
returnKeyLabel={strings.done()}
|
||||
returnKeyType="next"
|
||||
secureTextEntry
|
||||
@@ -210,7 +208,7 @@ export const SessionExpired = () => {
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder={strings.password()}
|
||||
onSubmit={() => {
|
||||
onSubmitEditing={() => {
|
||||
login();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
||||
import { db } from "../../common/database";
|
||||
import { DDS } from "../../services/device-detection";
|
||||
import { ToastManager } from "../../services/event-manager";
|
||||
import { clearMessage, setEmailVerifyMessage } from "../../services/message";
|
||||
import Navigation from "../../services/navigation";
|
||||
import { useUserStore } from "../../stores/use-user-store";
|
||||
@@ -39,13 +38,14 @@ import { AppFontSize } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { Loading } from "../loading";
|
||||
import { Button } from "../ui/button";
|
||||
import Input from "../ui/input";
|
||||
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { AuthHeader } from "./header";
|
||||
import { SignupContext } from "./signup-context";
|
||||
import { RouteParams } from "../../stores/use-navigation-store";
|
||||
import SettingsService from "../../services/settings";
|
||||
import AppIcon from "../ui/AppIcon";
|
||||
|
||||
const SignupSteps = {
|
||||
signup: 0,
|
||||
@@ -62,13 +62,17 @@ export const Signup = ({
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
|
||||
const { colors } = useThemeColors();
|
||||
const email = useRef<string>(undefined);
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
})
|
||||
);
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const password = useRef<string>(undefined);
|
||||
const confirmPasswordInputRef = useRef<TextInput>(null);
|
||||
const confirmPassword = useRef<string>(undefined);
|
||||
const [error, setError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const setLastSynced = useUserStore((state) => state.setLastSynced);
|
||||
@@ -76,29 +80,17 @@ export const Signup = ({
|
||||
const isTablet = width > 600;
|
||||
const route = useRoute<RouteProp<RouteParams, "Auth">>();
|
||||
|
||||
const validateInfo = () => {
|
||||
if (!password.current || !email.current || !confirmPassword.current) {
|
||||
ToastManager.show({
|
||||
heading: strings.allFieldsRequired(),
|
||||
message: strings.allFieldsRequiredDesc(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const signup = async () => {
|
||||
if (!validateInfo() || error) return;
|
||||
setErrorMessage(undefined);
|
||||
if (!formRef.current.validate()) return;
|
||||
if (loading) return;
|
||||
|
||||
const values = formRef.current.getValues();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
setCurrentStep(SignupSteps.createAccount);
|
||||
await db.user.signup(email.current!.toLowerCase(), password.current!);
|
||||
await db.user.signup(values.email.toLowerCase(), values.password);
|
||||
const user = await db.user.getUser();
|
||||
setUser(user);
|
||||
setLastSynced(await db.lastSynced());
|
||||
@@ -115,12 +107,14 @@ export const Signup = ({
|
||||
} catch (e) {
|
||||
setCurrentStep(SignupSteps.signup);
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: strings.signupFailed(),
|
||||
message: (e as Error).message,
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
if (
|
||||
(e as Error).message === "Unable to create an account on this email."
|
||||
) {
|
||||
formRef.current.setError("email", (e as Error).message);
|
||||
} else {
|
||||
setErrorMessage((e as Error).message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -215,60 +209,55 @@ export const Signup = ({
|
||||
alignSelf: "center"
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
<FormInput
|
||||
name="email"
|
||||
formRef={formRef}
|
||||
fwdRef={emailInputRef}
|
||||
onChangeText={(value) => {
|
||||
email.current = value;
|
||||
}}
|
||||
defaultValue={email.current}
|
||||
loading={loading}
|
||||
testID="input.email"
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
returnKeyLabel="Next"
|
||||
returnKeyType="next"
|
||||
autoComplete="email"
|
||||
validationType="email"
|
||||
keyboardType="email-address"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
errorMessage={strings.emailInvalid()}
|
||||
placeholder={strings.email()}
|
||||
blurOnSubmit={false}
|
||||
onSubmit={() => {
|
||||
if (!email.current) return;
|
||||
validators={[
|
||||
validators.required(strings.emailRequired()),
|
||||
validators.email(strings.enterAValidEmailAddress())
|
||||
]}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={passwordInputRef}
|
||||
onChangeText={(value) => {
|
||||
password.current = value;
|
||||
}}
|
||||
defaultValue={password.current}
|
||||
loading={loading}
|
||||
testID="input.password"
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
returnKeyLabel="Next"
|
||||
returnKeyType="next"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
autoCapitalize="none"
|
||||
blurOnSubmit={false}
|
||||
validationType="password"
|
||||
autoCorrect={false}
|
||||
placeholder={strings.password()}
|
||||
onSubmit={() => {
|
||||
if (!password.current) return;
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
onSubmitEditing={() => {
|
||||
confirmPasswordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="confirmPassword"
|
||||
formRef={formRef}
|
||||
fwdRef={confirmPasswordInputRef}
|
||||
onChangeText={(value) => {
|
||||
confirmPassword.current = value;
|
||||
}}
|
||||
defaultValue={confirmPassword.current}
|
||||
loading={loading}
|
||||
testID="input.confirmPassword"
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
returnKeyLabel="Signup"
|
||||
returnKeyType="done"
|
||||
secureTextEntry
|
||||
@@ -276,17 +265,22 @@ export const Signup = ({
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
blurOnSubmit={false}
|
||||
validationType="confirmPassword"
|
||||
customValidator={() => password.current!}
|
||||
placeholder={strings.confirmPassword()}
|
||||
marginBottom={12}
|
||||
onSubmit={() => {
|
||||
validators={[
|
||||
validators.required(strings.confirmPasswordRequired()),
|
||||
validators.matchField(
|
||||
"password",
|
||||
strings.passwordNotMatched()
|
||||
)
|
||||
]}
|
||||
onSubmitEditing={() => {
|
||||
signup();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={!loading ? "Continue" : null}
|
||||
title={!loading ? strings.continue() : null}
|
||||
type="accent"
|
||||
loading={loading}
|
||||
onPress={() => {
|
||||
@@ -320,6 +314,25 @@ export const Signup = ({
|
||||
</Paragraph>
|
||||
</Paragraph>
|
||||
</TouchableOpacity>
|
||||
|
||||
{errorMessage ? (
|
||||
<Paragraph
|
||||
numberOfLines={4}
|
||||
onPress={() => {}}
|
||||
color={colors.error.accent}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{errorMessage}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View
|
||||
|
||||
@@ -27,14 +27,15 @@ import useTimer from "../../hooks/use-timer";
|
||||
import { eSendEvent, ToastManager } from "../../services/event-manager";
|
||||
import { eCloseSimpleDialog } from "../../utils/events";
|
||||
import { AppFontSize } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { presentDialog } from "../dialog/functions";
|
||||
import AppIcon from "../ui/AppIcon";
|
||||
import { Button } from "../ui/button";
|
||||
import { IconButton } from "../ui/icon-button";
|
||||
import Input from "../ui/input";
|
||||
import { Pressable } from "../ui/pressable";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { presentDialog } from "../dialog/functions";
|
||||
|
||||
type MFAInfo = {
|
||||
primaryMethod: string;
|
||||
@@ -52,7 +53,8 @@ const TwoFactorVerification = ({
|
||||
method: string;
|
||||
code: string;
|
||||
},
|
||||
callback: (result: any) => void
|
||||
callback: (result: any) => void,
|
||||
onerror: (e: Error) => void
|
||||
) => Promise<void>;
|
||||
mfaInfo: MFAInfo;
|
||||
onCancel: () => void;
|
||||
@@ -66,15 +68,26 @@ const TwoFactorVerification = ({
|
||||
method: mfaInfo?.primaryMethod,
|
||||
isPrimary: true
|
||||
});
|
||||
const { seconds, start, reset } = useTimer(currentMethod.method!);
|
||||
const { seconds, start, reset, secondsRef } = useTimer(currentMethod.method!);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
|
||||
const onNext = async () => {
|
||||
if (!code.current || code.current.length < 6 || !currentMethod.method)
|
||||
if (!code.current || code.current.length < 6) {
|
||||
setError(
|
||||
new Error("Please provide a valid multi-factor authentication code.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentMethod.method) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
inputRef.current?.blur();
|
||||
await onMfaLogin(
|
||||
{
|
||||
@@ -86,6 +99,9 @@ const TwoFactorVerification = ({
|
||||
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
(e) => {
|
||||
setError(e);
|
||||
}
|
||||
);
|
||||
setLoading(false);
|
||||
@@ -131,7 +147,7 @@ const TwoFactorVerification = ({
|
||||
};
|
||||
|
||||
const onSendCode = useCallback(async () => {
|
||||
if (seconds || sending) return;
|
||||
if (secondsRef.current || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await db.mfa.sendCode(currentMethod.method as "sms" | "email");
|
||||
@@ -139,15 +155,18 @@ const TwoFactorVerification = ({
|
||||
setSending(false);
|
||||
} catch (e) {
|
||||
setSending(false);
|
||||
ToastManager.error(e as Error, "Error sending 2FA Code", "local");
|
||||
setError(
|
||||
new Error(`Error sending 2FA Code. Tap "Send code" to try again `)
|
||||
);
|
||||
}
|
||||
}, [currentMethod.method, mfaInfo.token, seconds, sending, start]);
|
||||
}, [currentMethod.method, secondsRef, sending, start]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMethod.method === "sms" || currentMethod.method === "email") {
|
||||
onSendCode();
|
||||
}
|
||||
}, [currentMethod.method, onSendCode]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentMethod.method]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -234,6 +253,7 @@ const TwoFactorVerification = ({
|
||||
fwdRef={inputRef}
|
||||
textAlign="center"
|
||||
onChangeText={(value) => {
|
||||
setError(undefined);
|
||||
code.current = value;
|
||||
}}
|
||||
cursorColor={colors.selected.accent}
|
||||
@@ -241,6 +261,7 @@ const TwoFactorVerification = ({
|
||||
selectionColor={colors.selected.accent}
|
||||
onSubmitEditing={onNext}
|
||||
height={60}
|
||||
marginBottom={0}
|
||||
inputStyle={{
|
||||
fontSize: AppFontSize.lg,
|
||||
textAlign: "center",
|
||||
@@ -254,10 +275,26 @@ const TwoFactorVerification = ({
|
||||
containerStyle={{
|
||||
minWidth: "50%"
|
||||
}}
|
||||
wrapperStyle={{
|
||||
height: 60
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<Paragraph
|
||||
numberOfLines={4}
|
||||
onPress={() => {}}
|
||||
color={colors.error.accent}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
maxWidth: 250
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{error?.message}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
title={loading ? null : strings.next()}
|
||||
@@ -338,7 +375,8 @@ TwoFactorVerification.present = (
|
||||
method: string;
|
||||
code: string;
|
||||
},
|
||||
callback: (result: any) => void
|
||||
callback: (result: any) => void,
|
||||
onerror: (e: Error) => void
|
||||
) => Promise<void>,
|
||||
data: MFAInfo,
|
||||
onCancel: () => void,
|
||||
|
||||
@@ -28,6 +28,7 @@ import SettingsService from "../../services/settings";
|
||||
import { useUserStore } from "../../stores/use-user-store";
|
||||
import { eCloseSimpleDialog } from "../../utils/events";
|
||||
import TwoFactorVerification from "./two-factor";
|
||||
import { createFormRef } from "../ui/input/form-input";
|
||||
|
||||
export const LoginSteps = {
|
||||
emailAuth: 1,
|
||||
@@ -39,49 +40,37 @@ export const useLogin = (
|
||||
onFinishLogin?: () => void,
|
||||
sessionExpired = false
|
||||
) => {
|
||||
const [error, setError] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const [step, setStep] = useState(LoginSteps.emailAuth);
|
||||
const email = useRef<string>(undefined);
|
||||
const password = useRef<string>(undefined);
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
|
||||
const validateInfo = () => {
|
||||
if (
|
||||
(!password.current && step === LoginSteps.passwordAuth) ||
|
||||
(!email.current && step === LoginSteps.emailAuth)
|
||||
) {
|
||||
ToastManager.show({
|
||||
heading: strings.allFieldsRequired(),
|
||||
message: strings.allFieldsRequiredDesc(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
email: "",
|
||||
password: ""
|
||||
})
|
||||
);
|
||||
|
||||
const login = async () => {
|
||||
if (!validateInfo() || error) return;
|
||||
try {
|
||||
if (loading) return;
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
switch (step) {
|
||||
case LoginSteps.emailAuth: {
|
||||
if (!email.current) {
|
||||
if (formRef.current.validateField("email")) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const mfaInfo = await db.user.authenticateEmail(email.current);
|
||||
const mfaInfo = await db.user.authenticateEmail(
|
||||
formRef.current.getValue("email")
|
||||
);
|
||||
|
||||
if (mfaInfo) {
|
||||
TwoFactorVerification.present(
|
||||
async (mfa: any, callback: (success: boolean) => void) => {
|
||||
async (mfa: any, callback: (success: boolean) => void, onerror: (e: Error) => void) => {
|
||||
try {
|
||||
const success = await db.user.authenticateMultiFactorCode(
|
||||
mfa.code,
|
||||
@@ -103,6 +92,9 @@ export const useLogin = (
|
||||
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
|
||||
setLoading(false);
|
||||
setStep(LoginSteps.emailAuth);
|
||||
ToastManager.error(new Error("Token expired, try logging in again"));
|
||||
} else {
|
||||
onerror(e as Error);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -119,13 +111,14 @@ export const useLogin = (
|
||||
break;
|
||||
}
|
||||
case LoginSteps.passwordAuth: {
|
||||
if (!email.current || !password.current) {
|
||||
if (!formRef.current.validate()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const values = formRef.current.getValues();
|
||||
await db.user.authenticatePassword(
|
||||
email.current,
|
||||
password.current,
|
||||
values.email,
|
||||
values.password,
|
||||
undefined,
|
||||
sessionExpired
|
||||
);
|
||||
@@ -142,12 +135,11 @@ export const useLogin = (
|
||||
const finishWithError = async (e: Error) => {
|
||||
if (e.message === "invalid_grant") setStep(LoginSteps.emailAuth);
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: strings.loginFailed(),
|
||||
message: e.message,
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
if (e.message === "Password is incorrect.") {
|
||||
formRef.current.setError("password", e.message);
|
||||
} else {
|
||||
setError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const finishLogin = async () => {
|
||||
@@ -174,13 +166,12 @@ export const useLogin = (
|
||||
login,
|
||||
step,
|
||||
setStep,
|
||||
email,
|
||||
password,
|
||||
passwordInputRef,
|
||||
emailInputRef,
|
||||
loading,
|
||||
setLoading,
|
||||
error,
|
||||
setError
|
||||
setError,
|
||||
formRef
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ const BaseDialog = ({
|
||||
? background
|
||||
: transparent
|
||||
? "transparent"
|
||||
: "rgba(0,0,0,0.3)"
|
||||
: "rgba(0,0,0,0.1)"
|
||||
}}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
|
||||
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { ActivityIndicator, StyleSheet, View } from "react-native";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { notesnook } from "../../../e2e/test.ids";
|
||||
import { getColorLinearShade } from "../../utils/colors";
|
||||
@@ -79,6 +79,11 @@ const DialogButtons = ({
|
||||
/>
|
||||
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
|
||||
</View>
|
||||
) : loading ? (
|
||||
<ActivityIndicator
|
||||
size={AppFontSize.lg}
|
||||
color={colors.primary.accent}
|
||||
/>
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
@@ -105,7 +110,6 @@ const DialogButtons = ({
|
||||
style={{
|
||||
marginLeft: 10
|
||||
}}
|
||||
loading={loading}
|
||||
bold
|
||||
type={positiveType || "transparent"}
|
||||
title={positiveTitle}
|
||||
|
||||
@@ -17,10 +17,12 @@ 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 { KeyboardTypeOptions } from "react-native";
|
||||
import { KeyboardTypeOptions, TextInput } from "react-native";
|
||||
import { eSendEvent } from "../../services/event-manager";
|
||||
import { eCloseSimpleDialog, eOpenSimpleDialog } from "../../utils/events";
|
||||
import { ButtonProps } from "../ui/button";
|
||||
import { FieldValidator, FormRef } from "../ui/input/form-input";
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type DialogInfo = {
|
||||
title?: string;
|
||||
@@ -43,6 +45,18 @@ export type DialogInfo = {
|
||||
| "errorShade";
|
||||
icon?: string;
|
||||
paragraphColor: string;
|
||||
form?: {
|
||||
formRef: FormRef;
|
||||
items: {
|
||||
name: string;
|
||||
placeholder: string;
|
||||
label?: string;
|
||||
validators: FieldValidator[];
|
||||
defaultValue?: string;
|
||||
ref: RefObject<TextInput | null>;
|
||||
}[];
|
||||
onFormSubmit?: (form: FormRef) => Promise<boolean>;
|
||||
};
|
||||
input: boolean;
|
||||
inputPlaceholder: string;
|
||||
defaultValue: string;
|
||||
|
||||
@@ -18,7 +18,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
RefObject
|
||||
} from "react";
|
||||
import { TextInput, View, ViewStyle } from "react-native";
|
||||
import { DDS } from "../../services/device-detection";
|
||||
import {
|
||||
@@ -34,6 +40,7 @@ import { sleep } from "../../utils/time";
|
||||
import { Toast } from "../toast";
|
||||
import { Button } from "../ui/button";
|
||||
import Input from "../ui/input";
|
||||
import { FormInput, type FormRef } from "../ui/input/form-input";
|
||||
import { Notice } from "../ui/notice";
|
||||
import Seperator from "../ui/seperator";
|
||||
import BaseDialog from "./base-dialog";
|
||||
@@ -53,15 +60,38 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
});
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [dialogInfo, setDialogInfo] = useState<DialogInfo>();
|
||||
const formRef = useRef(dialogInfo?.form?.formRef);
|
||||
formRef.current = dialogInfo?.form?.formRef;
|
||||
|
||||
const onPressPositive = async () => {
|
||||
if (dialogInfo?.positivePress) {
|
||||
// Handle form submission if form is available
|
||||
if (dialogInfo?.form && formRef.current) {
|
||||
inputRef.current?.blur();
|
||||
try {
|
||||
const isValid = await formRef.current.validate();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
if (dialogInfo.form.onFormSubmit) {
|
||||
setLoading(true);
|
||||
const result = await dialogInfo.form.onFormSubmit(formRef.current);
|
||||
if (result === false) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/** Empty */
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (dialogInfo?.positivePress) {
|
||||
// Handle old input-based submission
|
||||
inputRef.current?.blur();
|
||||
setLoading(true);
|
||||
let result = false;
|
||||
try {
|
||||
result = await dialogInfo.positivePress(
|
||||
values.current.inputValue || dialogInfo.defaultValue,
|
||||
values.current.inputValue,
|
||||
checked
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -76,6 +106,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
|
||||
setChecked(false);
|
||||
values.current.inputValue = undefined;
|
||||
formRef.current = undefined;
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
@@ -85,6 +116,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
if (data.context !== context) return;
|
||||
setDialogInfo(data);
|
||||
setChecked(data.check?.defaultValue);
|
||||
formRef.current = data?.form?.formRef;
|
||||
values.current.inputValue = data.defaultValue;
|
||||
setVisible(true);
|
||||
},
|
||||
@@ -94,6 +126,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
const hide = React.useCallback(() => {
|
||||
setChecked(false);
|
||||
values.current.inputValue = undefined;
|
||||
formRef.current = undefined;
|
||||
setVisible(false);
|
||||
setDialogInfo(undefined);
|
||||
dialogInfo?.onClose?.();
|
||||
@@ -134,19 +167,30 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
? false
|
||||
: dialogInfo.statusBarTranslucent
|
||||
}
|
||||
bounce={!dialogInfo.input}
|
||||
bounce={!dialogInfo.input && !dialogInfo.form}
|
||||
closeOnTouch={!dialogInfo.disableBackdropClosing}
|
||||
background={dialogInfo.background}
|
||||
transparent={
|
||||
dialogInfo.transparent === undefined ? true : dialogInfo.transparent
|
||||
dialogInfo.transparent === undefined ? false : dialogInfo.transparent
|
||||
}
|
||||
onShow={async () => {
|
||||
if (dialogInfo.input) {
|
||||
if (dialogInfo.input && !dialogInfo.form) {
|
||||
inputRef.current?.setNativeProps({
|
||||
text: dialogInfo.defaultValue
|
||||
});
|
||||
await sleep(300);
|
||||
inputRef.current?.focus();
|
||||
} else if (dialogInfo.form) {
|
||||
const items = dialogInfo.form?.items;
|
||||
const firstItem = items[0];
|
||||
for (const item of items) {
|
||||
if (item.defaultValue) {
|
||||
item.ref.current?.setNativeProps({
|
||||
text: dialogInfo.defaultValue
|
||||
});
|
||||
}
|
||||
}
|
||||
firstItem?.ref?.current?.focus();
|
||||
}
|
||||
}}
|
||||
visible={true}
|
||||
@@ -170,7 +214,36 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
/>
|
||||
<Seperator half />
|
||||
|
||||
{dialogInfo.input ? (
|
||||
{dialogInfo.form ? (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: DefaultAppStyles.GAP,
|
||||
gap: DefaultAppStyles.GAP / 2
|
||||
}}
|
||||
>
|
||||
{dialogInfo.form.items.map((item, index) => (
|
||||
<FormInput
|
||||
key={item.name}
|
||||
fwdRef={item.ref}
|
||||
name={item.name}
|
||||
autoFocus={index === 0}
|
||||
placeholder={item.placeholder}
|
||||
formRef={formRef as RefObject<FormRef>}
|
||||
validators={item.validators}
|
||||
defaultValue={item.defaultValue}
|
||||
secureTextEntry={dialogInfo.secureTextEntry}
|
||||
onSubmitEditing={() => {
|
||||
const nextItem = dialogInfo?.form?.items?.[index + 1];
|
||||
if (nextItem) {
|
||||
nextItem?.ref.current?.focus();
|
||||
} else {
|
||||
onPressPositive();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : dialogInfo.input ? (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: DefaultAppStyles.GAP
|
||||
@@ -184,7 +257,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
}}
|
||||
testID="input-value"
|
||||
secureTextEntry={dialogInfo.secureTextEntry}
|
||||
//defaultValue={dialogInfo.defaultValue}
|
||||
defaultValue={dialogInfo.defaultValue}
|
||||
onSubmit={() => {
|
||||
onPressPositive();
|
||||
}}
|
||||
@@ -237,7 +310,10 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
|
||||
<DialogButtons
|
||||
onPressNegative={onNegativePress}
|
||||
onPressPositive={dialogInfo.positivePress && onPressPositive}
|
||||
onPressPositive={
|
||||
(dialogInfo.positivePress || dialogInfo.form?.onFormSubmit) &&
|
||||
onPressPositive
|
||||
}
|
||||
loading={loading}
|
||||
positiveTitle={dialogInfo.positiveText}
|
||||
negativeTitle={dialogInfo.negativeText}
|
||||
|
||||
@@ -201,12 +201,14 @@ export const AppLockPassword = () => {
|
||||
accountPass
|
||||
? strings.enterAccountPassword()
|
||||
: mode === "change"
|
||||
? keyboardType === "pin"
|
||||
? strings.newPin()
|
||||
: strings.newPassword()
|
||||
: `${
|
||||
keyboardType === "pin" ? strings.pin() : strings.password()
|
||||
}`
|
||||
? keyboardType === "pin"
|
||||
? strings.newPin()
|
||||
: strings.newPassword()
|
||||
: `${
|
||||
keyboardType === "pin"
|
||||
? strings.pin()
|
||||
: strings.password()
|
||||
}`
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -364,7 +366,9 @@ export const AppLockPassword = () => {
|
||||
SettingsService.getProperty("biometricsAuthEnabled") === false
|
||||
) {
|
||||
SettingsService.setProperty("appLockEnabled", false);
|
||||
SettingsService.setPrivacyScreen(SettingsService.get());
|
||||
SettingsService.setPrivacyScreen(
|
||||
SettingsService.getProperty("privacyScreen")
|
||||
);
|
||||
ToastManager.show({
|
||||
message: strings.applockDisabled(),
|
||||
type: "success"
|
||||
@@ -383,8 +387,8 @@ export const AppLockPassword = () => {
|
||||
mode === "remove"
|
||||
? strings.remove()
|
||||
: mode === "change"
|
||||
? strings.change()
|
||||
: strings.save()
|
||||
? strings.change()
|
||||
: strings.save()
|
||||
}
|
||||
negativeTitle={strings.cancel()}
|
||||
positiveType="transparent"
|
||||
|
||||
@@ -17,7 +17,13 @@ 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 { GroupHeader, GroupOptions, ItemType } from "@notesnook/core";
|
||||
import {
|
||||
GroupHeader,
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
GroupOptions,
|
||||
ItemType
|
||||
} from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React from "react";
|
||||
@@ -40,7 +46,11 @@ type SectionHeaderProps = {
|
||||
color?: string;
|
||||
screen?: RouteName;
|
||||
groupOptions: GroupOptions;
|
||||
group: GroupingKey;
|
||||
groupId?: string;
|
||||
type?: GroupingByIdKey;
|
||||
onOpenJumpToDialog: () => void;
|
||||
itemCount?: number;
|
||||
};
|
||||
|
||||
export const SectionHeader = React.memo<
|
||||
@@ -53,11 +63,15 @@ export const SectionHeader = React.memo<
|
||||
color,
|
||||
screen,
|
||||
groupOptions,
|
||||
onOpenJumpToDialog
|
||||
group,
|
||||
onOpenJumpToDialog,
|
||||
itemCount,
|
||||
groupId,
|
||||
type
|
||||
}: SectionHeaderProps) {
|
||||
const { colors } = useThemeColors();
|
||||
const isCompactModeEnabled = useIsCompactModeEnabled(
|
||||
dataType as "note" | "notebook"
|
||||
dataType as "note" | "notebook" | "searchResult"
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -104,7 +118,9 @@ export const SectionHeader = React.memo<
|
||||
color={color || colors.primary.accent}
|
||||
>
|
||||
{!item.title || item.title === ""
|
||||
? strings.pinned().toUpperCase()
|
||||
? screen === "Search"
|
||||
? strings.results(itemCount || 0)
|
||||
: strings.pinned().toUpperCase()
|
||||
: item.title.toUpperCase()}
|
||||
</Heading>
|
||||
</Pressable>
|
||||
@@ -132,7 +148,10 @@ export const SectionHeader = React.memo<
|
||||
component: (
|
||||
<Sort
|
||||
screen={screen}
|
||||
type={dataType}
|
||||
dataType={dataType}
|
||||
type={type}
|
||||
group={group}
|
||||
groupId={groupId}
|
||||
hideGroupOptions={
|
||||
screen === "Reminders" || screen === "Search"
|
||||
}
|
||||
@@ -150,7 +169,8 @@ export const SectionHeader = React.memo<
|
||||
hidden={
|
||||
dataType !== "note" &&
|
||||
dataType !== "notebook" &&
|
||||
screen !== "Notes"
|
||||
screen !== "Notes" &&
|
||||
screen !== "Search"
|
||||
}
|
||||
style={{
|
||||
width: 25,
|
||||
@@ -163,9 +183,11 @@ export const SectionHeader = React.memo<
|
||||
}
|
||||
onPress={() => {
|
||||
SettingsService.set({
|
||||
[dataType !== "notebook"
|
||||
? "notesListMode"
|
||||
: "notebooksListMode"]: !isCompactModeEnabled
|
||||
[dataType === "notebook"
|
||||
? "notebooksListMode"
|
||||
: dataType === "searchResult"
|
||||
? "searchListMode"
|
||||
: "notesListMode"]: !isCompactModeEnabled
|
||||
? "compact"
|
||||
: "normal"
|
||||
});
|
||||
@@ -191,6 +213,7 @@ export const SectionHeader = React.memo<
|
||||
},
|
||||
(prev, next) => {
|
||||
if (prev.item.title !== next.item.title) return false;
|
||||
if (prev.itemCount !== next.itemCount) return false;
|
||||
if (prev.groupOptions?.groupBy !== next.groupOptions.groupBy) return false;
|
||||
if (prev.groupOptions?.sortDirection !== next.groupOptions.sortDirection)
|
||||
return false;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { eSendEvent } from "../../../services/event-manager";
|
||||
import { eOnLoadNote } from "../../../utils/events";
|
||||
import { IconButton } from "../../ui/icon-button";
|
||||
import { fluidTabsRef } from "../../../utils/global-refs";
|
||||
import { useSettingStore } from "../../../stores/use-setting-store";
|
||||
type SearchResultProps = {
|
||||
item: HighlightedResult;
|
||||
};
|
||||
@@ -38,6 +39,9 @@ type SearchResultProps = {
|
||||
export const SearchResult = (props: SearchResultProps) => {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const { colors } = useThemeColors();
|
||||
const compactMode = useSettingStore(
|
||||
(state) => state.settings.searchListMode === "compact"
|
||||
);
|
||||
|
||||
const openNote = async (index?: number) => {
|
||||
const note = await db.notes.note(props.item.id);
|
||||
@@ -87,7 +91,7 @@ export const SearchResult = (props: SearchResultProps) => {
|
||||
flexShrink: 1
|
||||
}}
|
||||
>
|
||||
{props.item.content?.length ? (
|
||||
{props.item.content?.length && !compactMode ? (
|
||||
<IconButton
|
||||
name={!expanded ? "chevron-right" : "chevron-down"}
|
||||
onPress={() => setExpanded((prev) => !prev)}
|
||||
@@ -130,6 +134,7 @@ export const SearchResult = (props: SearchResultProps) => {
|
||||
</View>
|
||||
|
||||
{expanded &&
|
||||
!compactMode &&
|
||||
props.item.content.map((content, index) => (
|
||||
<Pressable
|
||||
key={props.item.id + index}
|
||||
|
||||
@@ -17,7 +17,12 @@ 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 { GroupingKey, Item, VirtualizedGrouping } from "@notesnook/core";
|
||||
import {
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
Item,
|
||||
VirtualizedGrouping
|
||||
} from "@notesnook/core";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import { LegendList, LegendListRenderItemProps } from "@legendapp/list";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
@@ -53,7 +58,9 @@ type ListProps = {
|
||||
isRenderedInActionSheet?: boolean;
|
||||
CustomListComponent?: React.JSX.ElementType;
|
||||
placeholder?: PlaceholderData;
|
||||
groupType: GroupingKey;
|
||||
id?: string;
|
||||
type?: GroupingByIdKey;
|
||||
};
|
||||
|
||||
const onMomentumScrollEnd = () => {
|
||||
@@ -73,16 +80,7 @@ export default function List(props: ListProps) {
|
||||
props.dataType === "notebook" ||
|
||||
notebooksListMode === "compact";
|
||||
|
||||
const groupType =
|
||||
props.renderedInRoute === "Notes"
|
||||
? "home"
|
||||
: props.renderedInRoute === "Favorites"
|
||||
? "favorites"
|
||||
: props.renderedInRoute === "Trash" || props.dataType === "trash"
|
||||
? "trash"
|
||||
: `${props.dataType}s`;
|
||||
|
||||
const groupOptions = useGroupOptions(groupType);
|
||||
const groupOptions = useGroupOptions(props.groupType, props.id, props.type);
|
||||
|
||||
const _onRefresh = async () => {
|
||||
Sync.run("global", false, "full", () => {
|
||||
@@ -104,23 +102,27 @@ export default function List(props: ListProps) {
|
||||
index={itemProps.index}
|
||||
isSheet={props.isRenderedInActionSheet || false}
|
||||
items={props.data}
|
||||
groupId={props.id}
|
||||
groupOptions={groupOptions}
|
||||
group={groupType as GroupingKey}
|
||||
group={props.groupType as GroupingKey}
|
||||
renderedInRoute={props.renderedInRoute}
|
||||
customAccentColor={props.customAccentColor}
|
||||
dataType={props.dataType}
|
||||
type={props.type}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
groupOptions,
|
||||
groupType,
|
||||
props.customAccentColor,
|
||||
props.data,
|
||||
props.dataType,
|
||||
props.isRenderedInActionSheet,
|
||||
props.renderedInRoute
|
||||
props.data,
|
||||
props.id,
|
||||
props.groupType,
|
||||
props.renderedInRoute,
|
||||
props.customAccentColor,
|
||||
props.dataType,
|
||||
groupOptions,
|
||||
props.type
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Color,
|
||||
GroupHeader,
|
||||
GroupOptions,
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
HighlightedResult,
|
||||
Item,
|
||||
@@ -40,7 +41,7 @@ import {
|
||||
} from "@notesnook/core";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { db } from "../../common/database";
|
||||
import { getGroupOptions } from "../../hooks/use-group-options";
|
||||
import { useIsCompactModeEnabled } from "../../hooks/use-is-compact-mode-enabled";
|
||||
import { eSendEvent } from "../../services/event-manager";
|
||||
import { RouteName } from "../../stores/use-navigation-store";
|
||||
@@ -49,11 +50,11 @@ import { SectionHeader } from "../list-items/headers/section-header";
|
||||
import { NoteWrapper } from "../list-items/note/wrapper";
|
||||
import { NotebookWrapper } from "../list-items/notebook/wrapper";
|
||||
import ReminderItem from "../list-items/reminder";
|
||||
import TagItem from "../list-items/tag";
|
||||
import { SearchResult } from "../list-items/search-result";
|
||||
import TagItem from "../list-items/tag";
|
||||
|
||||
type ListItemWrapperProps<TItem = Item> = {
|
||||
group?: GroupingKey;
|
||||
group: GroupingKey;
|
||||
items: VirtualizedGrouping<TItem> | undefined;
|
||||
isSheet: boolean;
|
||||
index: number;
|
||||
@@ -62,6 +63,8 @@ type ListItemWrapperProps<TItem = Item> = {
|
||||
dataType: string;
|
||||
scrollRef: any;
|
||||
groupOptions: GroupOptions;
|
||||
groupId?: string;
|
||||
type?: GroupingByIdKey;
|
||||
};
|
||||
|
||||
export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
@@ -182,6 +185,9 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
dataType={item.type}
|
||||
group={group}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -218,6 +224,9 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
dataType={item.type}
|
||||
group={group}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -245,8 +254,11 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
screen={props.renderedInRoute}
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
color={props.customAccentColor}
|
||||
type={props.type}
|
||||
groupId={props.groupId}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
eSendEvent(eOpenJumpToDialog, {
|
||||
@@ -271,7 +283,10 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
screen={props.renderedInRoute}
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -297,9 +312,13 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
screen={props.renderedInRoute}
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
itemCount={items?.placeholders.length}
|
||||
onOpenJumpToDialog={() => {
|
||||
eSendEvent(eOpenJumpToDialog, {
|
||||
ref: props.scrollRef,
|
||||
@@ -316,11 +335,16 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function getDate(item: Notebook | Note, groupType?: GroupingKey): number {
|
||||
function getDate(
|
||||
item: Notebook | Note,
|
||||
groupType?: GroupingKey,
|
||||
id?: string,
|
||||
type?: GroupingByIdKey
|
||||
): number {
|
||||
return (
|
||||
getSortValue(
|
||||
groupType
|
||||
? db.settings.getGroupOptions(groupType)
|
||||
? getGroupOptions(groupType, id, type)
|
||||
: {
|
||||
sortBy: "dateEdited",
|
||||
sortDirection: "desc"
|
||||
|
||||
@@ -76,7 +76,9 @@ const MergeConflicts = () => {
|
||||
const applyChanges = async () => {
|
||||
const contentToSave = selectedContent;
|
||||
if (!contentToSave) return;
|
||||
const note = await db.notes.note(contentToSave.noteId);
|
||||
const note = await db.notes.note(
|
||||
(content.current as UnencryptedContentItem).noteId
|
||||
);
|
||||
if (!note) return;
|
||||
await db.notes.add({
|
||||
id: note.id,
|
||||
|
||||
@@ -17,18 +17,18 @@ 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 { Debug } from "@notesnook/core";
|
||||
import { Debug, IssueReportResponse } from "@notesnook/core";
|
||||
import { getModel, getBrand, getSystemVersion } from "react-native-device-info";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Linking, Platform, Text, TextInput, View } from "react-native";
|
||||
import { getVersion } from "react-native-device-info";
|
||||
import { useStoredRef } from "../../../hooks/use-stored-ref";
|
||||
import { ToastManager } from "../../../services/event-manager";
|
||||
import { eSendEvent, ToastManager } from "../../../services/event-manager";
|
||||
import PremiumService from "../../../services/premium";
|
||||
import { useUserStore } from "../../../stores/use-user-store";
|
||||
import { openLinkInBrowser } from "../../../utils/functions";
|
||||
import { defaultBorderRadius, AppFontSize } from "../../../utils/size";
|
||||
import { defaultBorderRadius, AppFontSize } from "../../../utils/size/index";
|
||||
import DialogHeader from "../../dialog/dialog-header";
|
||||
import { Button } from "../../ui/button";
|
||||
import Seperator from "../../ui/seperator";
|
||||
@@ -37,17 +37,26 @@ import Paragraph from "../../ui/typography/paragraph";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import Config from "react-native-config";
|
||||
import { eCloseSheet } from "../../../utils/events";
|
||||
|
||||
export const Issue = ({ defaultTitle, defaultBody, issueTitle }) => {
|
||||
export const Issue = ({
|
||||
defaultTitle,
|
||||
defaultBody,
|
||||
issueTitle
|
||||
}: {
|
||||
defaultTitle?: string;
|
||||
defaultBody?: string;
|
||||
issueTitle?: string;
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
const body = useStoredRef("issueBody");
|
||||
const body = useStoredRef("issueBody", "");
|
||||
const title = useStoredRef("issueTitle", defaultTitle);
|
||||
const [done, setDone] = useState(false);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const bodyRef = useRef();
|
||||
const bodyRef = useRef<TextInput>(null);
|
||||
const initialLayout = useRef(false);
|
||||
const issueUrl = useRef();
|
||||
const issueReportResponse = useRef<IssueReportResponse>(undefined);
|
||||
|
||||
const onPress = async () => {
|
||||
if (loading) return;
|
||||
@@ -57,7 +66,7 @@ export const Issue = ({ defaultTitle, defaultBody, issueTitle }) => {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
issueUrl.current = await Debug.report({
|
||||
issueReportResponse.current = await Debug.report({
|
||||
title: title.current,
|
||||
body:
|
||||
body.current +
|
||||
@@ -72,7 +81,7 @@ Logged in: ${user ? "yes" : "no"}
|
||||
Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
|
||||
userId: user?.id
|
||||
});
|
||||
if (!issueUrl.current) {
|
||||
if (!issueReportResponse.current) {
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: "Failed to report issue on github",
|
||||
@@ -88,12 +97,46 @@ Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: e.message,
|
||||
heading: (e as Error).message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function getResponseInfo(response?: IssueReportResponse) {
|
||||
if (!response || "error" in response) return;
|
||||
switch (response.type) {
|
||||
case "email": {
|
||||
return {
|
||||
title: strings.yourSupportRequestHasBeenForwarded(),
|
||||
message: strings.supportEmailMessage()
|
||||
};
|
||||
}
|
||||
case "discussion": {
|
||||
const url = response.url;
|
||||
return {
|
||||
title: strings.thankYouForFeedback(),
|
||||
positiveButtonText: strings.copyLink(),
|
||||
message: strings.featureRequestMessage(url),
|
||||
url: url
|
||||
};
|
||||
}
|
||||
case "issue": {
|
||||
const url = response.url;
|
||||
return {
|
||||
title: strings.thankYouForReporting(),
|
||||
positiveButtonText: strings.copyLink(),
|
||||
message: strings.bugReportMessage(url),
|
||||
url: url
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const responseInfo = getResponseInfo(issueReportResponse.current);
|
||||
|
||||
console.log(responseInfo, issueReportResponse.current);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -105,38 +148,28 @@ Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
height: 250,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 10
|
||||
}}
|
||||
>
|
||||
<Heading>{strings.issueCreatedHeading()}</Heading>
|
||||
<Heading>{responseInfo?.title}</Heading>
|
||||
<Paragraph
|
||||
style={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
selectable={true}
|
||||
>
|
||||
{strings.issueCreatedDesc[0]()}
|
||||
<Paragraph
|
||||
style={{
|
||||
textDecorationLine: "underline",
|
||||
color: colors.primary.accent
|
||||
}}
|
||||
onPress={() => {
|
||||
Linking.openURL(issueUrl.current);
|
||||
}}
|
||||
>
|
||||
{issueUrl.current}
|
||||
</Paragraph>
|
||||
. {strings.issueCreatedDesc[1]()}
|
||||
{responseInfo?.message}
|
||||
</Paragraph>
|
||||
|
||||
<Button
|
||||
title={strings.openIssue()}
|
||||
title={responseInfo?.positiveButtonText || "Done"}
|
||||
onPress={() => {
|
||||
Linking.openURL(issueUrl.current);
|
||||
if (responseInfo?.url) {
|
||||
Linking.openURL(responseInfo?.url);
|
||||
}
|
||||
eSendEvent(eCloseSheet);
|
||||
}}
|
||||
type="accent"
|
||||
width="100%"
|
||||
@@ -248,10 +281,7 @@ Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
|
||||
}}
|
||||
onPress={async () => {
|
||||
try {
|
||||
await openLinkInBrowser(
|
||||
"https://discord.gg/zQBK97EE22",
|
||||
colors
|
||||
);
|
||||
await openLinkInBrowser("https://discord.gg/zQBK97EE22");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -45,12 +45,15 @@ import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import DialogHeader from "../../dialog/dialog-header";
|
||||
import { Button } from "../../ui/button";
|
||||
import { IconButton } from "../../ui/icon-button";
|
||||
import Input from "../../ui/input";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { useAsync } from "react-async-hook";
|
||||
import { eMenuItemUpdate } from "../../../utils/events";
|
||||
import { useIsFeatureAvailable } from "@notesnook/common";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../ui/input/form-input";
|
||||
|
||||
async function fetchMonographData(noteId: string) {
|
||||
const monographId = db.monographs.monograph(noteId);
|
||||
@@ -76,26 +79,31 @@ const PublishNoteSheet = ({
|
||||
const isFeatureAvailable = useIsFeatureAvailable("monographAnalytics");
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const customTitle = useRef<string>("");
|
||||
const pwdInput = useRef<TextInput>(null);
|
||||
const titleInput = useRef<TextInput>(null);
|
||||
const passwordValue = useRef<string>(undefined);
|
||||
const monographData = useAsync(async () => {
|
||||
return fetchMonographData(note?.id);
|
||||
}, []);
|
||||
const monograph = monographData.result?.monograph;
|
||||
customTitle.current = monograph?.title || note.title || "";
|
||||
const publishUrl = monograph && `${hosts.MONOGRAPH_HOST}/${monograph?.id}`;
|
||||
const isPublished = db.monographs.monograph(note?.id);
|
||||
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
title: monograph?.title || note.title || "",
|
||||
password: ""
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (monograph) {
|
||||
setSelfDestruct(!!monograph?.selfDestruct);
|
||||
if (monograph.password) {
|
||||
passwordValue.current = await db.monographs.decryptPassword(
|
||||
const password = await db.monographs.decryptPassword(
|
||||
monograph?.password
|
||||
);
|
||||
formRef.current.setValue("password", password);
|
||||
setIsLocked(!!monograph?.password);
|
||||
}
|
||||
}
|
||||
@@ -104,20 +112,23 @@ const PublishNoteSheet = ({
|
||||
|
||||
const publishNote = async () => {
|
||||
if (publishing) return;
|
||||
formRef.current.clearErrors();
|
||||
|
||||
if (!formRef.current.validate()) return;
|
||||
|
||||
const values = formRef.current.getValues();
|
||||
|
||||
setPublishLoading(true);
|
||||
|
||||
try {
|
||||
if (note?.id) {
|
||||
if (isLocked && !passwordValue.current) return;
|
||||
await db.monographs.publish(note.id, customTitle.current, {
|
||||
selfDestruct: selfDestruct,
|
||||
password: isLocked ? passwordValue.current : undefined
|
||||
await db.monographs.publish(note.id, values.title, {
|
||||
selfDestruct,
|
||||
password: isLocked ? values.password : undefined
|
||||
});
|
||||
|
||||
await monographData.execute();
|
||||
Navigation.queueRoutesForUpdate();
|
||||
eSendEvent(eMenuItemUpdate);
|
||||
setPublishLoading(false);
|
||||
}
|
||||
requestInAppReview();
|
||||
} catch (e) {
|
||||
@@ -127,9 +138,9 @@ const PublishNoteSheet = ({
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
} finally {
|
||||
setPublishLoading(false);
|
||||
}
|
||||
|
||||
setPublishLoading(false);
|
||||
};
|
||||
const setPublishLoading = (value: boolean) => {
|
||||
setPublishing(value);
|
||||
@@ -247,11 +258,12 @@ const PublishNoteSheet = ({
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="title"
|
||||
formRef={formRef}
|
||||
fwdRef={titleInput}
|
||||
onChangeText={(value) => (customTitle.current = value)}
|
||||
defaultValue={customTitle.current}
|
||||
placeholder={strings.noteTitle()}
|
||||
validators={[validators.required(strings.titleIsRequired())]}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -298,13 +310,16 @@ const PublishNoteSheet = ({
|
||||
|
||||
{isLocked ? (
|
||||
<>
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={pwdInput}
|
||||
onChangeText={(value) => (passwordValue.current = value)}
|
||||
blurOnSubmit
|
||||
secureTextEntry
|
||||
defaultValue={passwordValue.current}
|
||||
placeholder={strings.enterPassword()}
|
||||
validators={[
|
||||
validators.required(strings.passwordRequired())
|
||||
]}
|
||||
containerStyle={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/*
|
||||
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, ItemReference, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { RefObject, useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../../common/database";
|
||||
import {
|
||||
PresentSheetOptions,
|
||||
presentSheet
|
||||
} from "../../../services/event-manager";
|
||||
import { useRelationStore } from "../../../stores/use-relation-store";
|
||||
import { AppFontSize } from "../../../utils/size";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import DialogHeader from "../../dialog/dialog-header";
|
||||
import List from "../../list";
|
||||
import SheetProvider from "../../sheet-provider";
|
||||
import { Button, ButtonProps } from "../../ui/button";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
|
||||
type RelationsListProps = {
|
||||
actionSheetRef: RefObject<ActionSheetRef>;
|
||||
close?: () => void;
|
||||
update?: (options: PresentSheetOptions) => void;
|
||||
item: { id: string; type: string };
|
||||
referenceType: string;
|
||||
relationType: "to" | "from";
|
||||
title: string;
|
||||
button?: ButtonProps;
|
||||
onAdd: () => void;
|
||||
};
|
||||
|
||||
const IconsByType = {
|
||||
reminder: "bell"
|
||||
};
|
||||
|
||||
export const RelationsList = ({
|
||||
actionSheetRef,
|
||||
item,
|
||||
referenceType,
|
||||
relationType,
|
||||
title,
|
||||
button,
|
||||
onAdd
|
||||
}: RelationsListProps) => {
|
||||
const updater = useRelationStore((state) => state.updater);
|
||||
const { colors } = useThemeColors();
|
||||
const [items, setItems] = useState<VirtualizedGrouping<Item>>();
|
||||
const hasNoRelations = !items || items?.placeholders?.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
db.relations?.[relationType]?.(
|
||||
{ id: item?.id, type: item?.type } as ItemReference,
|
||||
referenceType as any
|
||||
)
|
||||
.selector.sorted({
|
||||
sortBy: "dateEdited",
|
||||
sortDirection: "desc"
|
||||
})
|
||||
.then((grouped) => {
|
||||
setTimeout(() => {
|
||||
setItems(grouped);
|
||||
}, 300);
|
||||
});
|
||||
}, [relationType, referenceType, item?.id, item?.type, updater]);
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: DefaultAppStyles.GAP, height: "100%" }}>
|
||||
<SheetProvider context="local" />
|
||||
<DialogHeader
|
||||
title={title}
|
||||
button={hasNoRelations ? undefined : button}
|
||||
/>
|
||||
{hasNoRelations ? (
|
||||
<View
|
||||
style={{
|
||||
height: "85%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={IconsByType[referenceType as keyof typeof IconsByType]}
|
||||
size={60}
|
||||
color={colors.primary.icon}
|
||||
/>
|
||||
<Paragraph>{strings.noLinksFound()}</Paragraph>
|
||||
<Button
|
||||
onPress={() => {
|
||||
onAdd?.();
|
||||
}}
|
||||
fontSize={AppFontSize.sm}
|
||||
// width="100%"
|
||||
type="inverted"
|
||||
icon="plus"
|
||||
title={strings.addItem(
|
||||
referenceType as "notebook" | "tag" | "reminder" | "note"
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<List
|
||||
data={items}
|
||||
loading={false}
|
||||
dataType={referenceType as any}
|
||||
isRenderedInActionSheet={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
RelationsList.present = ({
|
||||
reference,
|
||||
referenceType,
|
||||
relationType,
|
||||
title,
|
||||
button,
|
||||
onAdd
|
||||
}: {
|
||||
reference: { id: string; type: string };
|
||||
referenceType: string;
|
||||
relationType: "to" | "from";
|
||||
title: string;
|
||||
button?: ButtonProps;
|
||||
onAdd: () => void;
|
||||
}) => {
|
||||
presentSheet({
|
||||
component: (ref, close, update) => (
|
||||
<RelationsList
|
||||
actionSheetRef={ref}
|
||||
close={close}
|
||||
update={update}
|
||||
item={reference}
|
||||
referenceType={referenceType}
|
||||
relationType={relationType}
|
||||
title={title}
|
||||
button={button}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
)
|
||||
});
|
||||
};
|
||||
@@ -181,6 +181,7 @@ export default function ReminderNotify({
|
||||
data={references}
|
||||
loading={false}
|
||||
dataType="note"
|
||||
groupType="notes"
|
||||
isRenderedInActionSheet={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
GroupOptions,
|
||||
ItemType,
|
||||
@@ -27,7 +28,10 @@ import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { db } from "../../../common/database";
|
||||
import {
|
||||
getGroupOptions,
|
||||
setGroupOptionsById
|
||||
} from "../../../hooks/use-group-options";
|
||||
import { eSendEvent } from "../../../services/event-manager";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { RouteName } from "../../../stores/use-navigation-store";
|
||||
@@ -43,29 +47,24 @@ import { Pressable } from "../../ui/pressable";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
const Sort = ({
|
||||
type,
|
||||
dataType,
|
||||
screen,
|
||||
hideGroupOptions
|
||||
hideGroupOptions,
|
||||
group: groupType,
|
||||
groupId,
|
||||
type
|
||||
}: {
|
||||
type: ItemType;
|
||||
dataType: ItemType;
|
||||
type?: GroupingByIdKey;
|
||||
screen?: RouteName;
|
||||
group: GroupingKey;
|
||||
hideGroupOptions?: boolean;
|
||||
groupId?: string;
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
const groupType =
|
||||
screen === "Archive"
|
||||
? "archive"
|
||||
: screen === "Search"
|
||||
? "search"
|
||||
: screen === "Notes"
|
||||
? "home"
|
||||
: screen === "Trash" || type === "trash"
|
||||
? "trash"
|
||||
: ((type + "s") as GroupingKey);
|
||||
|
||||
const [groupOptions, setGroupOptions] = useState(
|
||||
db.settings.getGroupOptions(groupType)
|
||||
getGroupOptions(groupType, groupId, type)
|
||||
);
|
||||
|
||||
const getSortButtonTitle = () => {
|
||||
@@ -88,16 +87,17 @@ const Sort = ({
|
||||
};
|
||||
|
||||
const updateGroupOptions = async (_groupOptions: GroupOptions) => {
|
||||
await db.settings.setGroupOptions(groupType, _groupOptions);
|
||||
console.log(groupId, type);
|
||||
setGroupOptionsById(groupType, _groupOptions, groupId, type);
|
||||
setGroupOptions(_groupOptions);
|
||||
setTimeout(() => {
|
||||
if (screen) Navigation.queueRoutesForUpdate(screen);
|
||||
if (type === "notebook") {
|
||||
if (dataType === "notebook") {
|
||||
useNotebookStore.getState().refresh();
|
||||
} else if (type === "tag") {
|
||||
} else if (dataType === "tag") {
|
||||
useTagStore.getState().refresh();
|
||||
}
|
||||
eSendEvent(eGroupOptionsUpdated, groupType);
|
||||
eSendEvent(eGroupOptionsUpdated, groupType, groupId, type);
|
||||
eSendEvent(refreshNotesPage);
|
||||
}, 1);
|
||||
};
|
||||
@@ -157,10 +157,15 @@ const Sort = ({
|
||||
>
|
||||
{Object.keys(SORT).map((item) => {
|
||||
const sortOptionVisibility = {
|
||||
relevance: screen === "Search",
|
||||
dueDate: screen === "Reminders",
|
||||
dateModified: screen === "Tags" || screen === "Reminders",
|
||||
dateEdited: screen !== "Tags" && screen !== "Reminders"
|
||||
dateCreated: groupType !== "trash",
|
||||
relevance: groupType === "search",
|
||||
dueDate: groupType === "reminders",
|
||||
dateModified: groupType === "reminders" || groupType === "tags",
|
||||
dateEdited:
|
||||
groupType !== "tags" &&
|
||||
groupType !== "reminders" &&
|
||||
groupType !== "trash",
|
||||
dateDeleted: groupType === "trash"
|
||||
};
|
||||
|
||||
// Check if this sort option should be skipped for the current screen
|
||||
@@ -188,10 +193,7 @@ const Sort = ({
|
||||
onPress={async () => {
|
||||
const _groupOptions: GroupOptions = {
|
||||
...groupOptions,
|
||||
sortBy:
|
||||
type === "trash"
|
||||
? "dateDeleted"
|
||||
: (item as SortOptions["sortBy"])
|
||||
sortBy: item as SortOptions["sortBy"]
|
||||
};
|
||||
await updateGroupOptions(_groupOptions);
|
||||
}}
|
||||
|
||||
@@ -433,7 +433,7 @@ const TabBar = (props: SimpleTabBarProps) => {
|
||||
name="plus"
|
||||
testID="sidebar-add-button"
|
||||
size={AppFontSize.lg - 2}
|
||||
top={10}
|
||||
top={10}
|
||||
color={colors.primary.icon}
|
||||
onPress={async () => {
|
||||
if (props.navigationState.index === 1) {
|
||||
@@ -493,11 +493,16 @@ const TabBar = (props: SimpleTabBarProps) => {
|
||||
presentSheet({
|
||||
component: (
|
||||
<Sort
|
||||
type={
|
||||
dataType={
|
||||
props.navigationState.index === 1
|
||||
? "notebook"
|
||||
: "tag"
|
||||
}
|
||||
group={
|
||||
props.navigationState.index === 1
|
||||
? "notebooks"
|
||||
: "tags"
|
||||
}
|
||||
hideGroupOptions
|
||||
/>
|
||||
)
|
||||
|
||||
411
apps/mobile/app/components/ui/input/form-input.tsx
Normal file
411
apps/mobile/app/components/ui/input/form-input.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { RefObject, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ColorValue,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from "react-native";
|
||||
import isEmail from "validator/lib/isEmail";
|
||||
import isURL from "validator/lib/isURL";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import { defaultBorderRadius, AppFontSize } from "../../../utils/size";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import { IconButton } from "../icon-button";
|
||||
import Paragraph from "../typography/paragraph";
|
||||
import AppIcon from "../AppIcon";
|
||||
|
||||
export type FormValues = Record<string, string>;
|
||||
export type FormErrors = Partial<Record<string, string>>;
|
||||
export type FieldValidator = (
|
||||
value: string,
|
||||
values: FormValues
|
||||
) => string | undefined;
|
||||
export type ValidationSchema = Partial<Record<string, FieldValidator[]>>;
|
||||
|
||||
export interface FormRef {
|
||||
values: FormValues;
|
||||
errors: FormErrors;
|
||||
setValue: (name: string, value: string) => void;
|
||||
getValue: (name: string) => string;
|
||||
getValues: () => FormValues;
|
||||
setError: (name: string, error?: string) => void;
|
||||
getError: (name: string) => string | undefined;
|
||||
clearErrors: () => void;
|
||||
registerField: (name: string, validators?: FieldValidator[]) => void;
|
||||
unregisterField: (name: string) => void;
|
||||
validateField: (name: string) => string | undefined;
|
||||
validate: () => boolean;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export function createFormRef(initialValues: FormValues = {}): FormRef {
|
||||
const listeners = new Set<() => void>();
|
||||
const values: FormValues = { ...initialValues };
|
||||
const errors: FormErrors = {};
|
||||
const schema: ValidationSchema = {};
|
||||
|
||||
const notify = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
setValue(name, value) {
|
||||
values[name] = value;
|
||||
if (errors[name]) {
|
||||
delete errors[name];
|
||||
notify();
|
||||
}
|
||||
},
|
||||
getValue(name) {
|
||||
return values[name] ?? "";
|
||||
},
|
||||
getValues() {
|
||||
return { ...values };
|
||||
},
|
||||
setError(name, error) {
|
||||
if (!error) {
|
||||
delete errors[name];
|
||||
} else {
|
||||
errors[name] = error;
|
||||
}
|
||||
notify();
|
||||
},
|
||||
getError(name) {
|
||||
return errors[name];
|
||||
},
|
||||
clearErrors() {
|
||||
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||
notify();
|
||||
},
|
||||
registerField(name, validators = []) {
|
||||
schema[name] = validators;
|
||||
if (values[name] === undefined) values[name] = "";
|
||||
},
|
||||
unregisterField(name) {
|
||||
delete schema[name];
|
||||
delete errors[name];
|
||||
notify();
|
||||
},
|
||||
validateField(name) {
|
||||
const fieldValidators = schema[name] || [];
|
||||
const value = values[name] ?? "";
|
||||
for (const validator of fieldValidators) {
|
||||
const error = validator(value, values);
|
||||
if (error) {
|
||||
errors[name] = error;
|
||||
notify();
|
||||
return error;
|
||||
}
|
||||
}
|
||||
delete errors[name];
|
||||
notify();
|
||||
return undefined;
|
||||
},
|
||||
validate() {
|
||||
const nextErrors = validateForm(values, schema);
|
||||
Object.keys(errors).forEach((key) => delete errors[key]);
|
||||
Object.keys(nextErrors).forEach((key) => {
|
||||
errors[key] = nextErrors[key];
|
||||
});
|
||||
notify();
|
||||
return !hasFormErrors(nextErrors);
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validateForm(
|
||||
values: FormValues,
|
||||
schema: ValidationSchema
|
||||
): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
Object.keys(schema).forEach((field) => {
|
||||
const fieldValidators = schema[field] || [];
|
||||
const value = values[field] ?? "";
|
||||
for (const validator of fieldValidators) {
|
||||
const error = validator(value, values);
|
||||
if (error) {
|
||||
errors[field] = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function hasFormErrors(errors: FormErrors) {
|
||||
return Object.keys(errors).length > 0;
|
||||
}
|
||||
|
||||
export const validators = {
|
||||
required:
|
||||
(message = "This field is required") =>
|
||||
(value: string) =>
|
||||
value?.trim() ? undefined : message,
|
||||
|
||||
email:
|
||||
(message = "Please enter a valid email") =>
|
||||
(value: string) =>
|
||||
!value?.trim() || isEmail(value.trim()) ? undefined : message,
|
||||
|
||||
minLength: (length: number, message?: string) => (value: string) =>
|
||||
!value || value.length >= length
|
||||
? undefined
|
||||
: message || `Must be at least ${length} characters`,
|
||||
|
||||
url:
|
||||
(message = "Please enter a valid URL") =>
|
||||
(value: string) =>
|
||||
!value?.trim() || isURL(value.trim(), { allow_underscores: true })
|
||||
? undefined
|
||||
: message,
|
||||
|
||||
matchField:
|
||||
(fieldName: string, message = "Values do not match") =>
|
||||
(value: string, values: FormValues) =>
|
||||
value === values[fieldName] ? undefined : message
|
||||
};
|
||||
|
||||
interface FormInputProps extends TextInputProps {
|
||||
name: string;
|
||||
formRef: RefObject<FormRef>;
|
||||
validators?: FieldValidator[];
|
||||
fwdRef?: RefObject<TextInput | null>;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
customColor?: ColorValue;
|
||||
marginBottom?: number;
|
||||
marginRight?: number;
|
||||
button?: {
|
||||
icon: string;
|
||||
color: ColorValue;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
size?: number;
|
||||
};
|
||||
buttons?: React.ReactNode;
|
||||
buttonLeft?: React.ReactNode;
|
||||
height?: number;
|
||||
fontSize?: number;
|
||||
inputStyle?: TextInputProps["style"];
|
||||
containerStyle?: ViewStyle;
|
||||
wrapperStyle?: ViewStyle;
|
||||
errorStyle?: TextStyle;
|
||||
}
|
||||
|
||||
export function FormInput({
|
||||
name,
|
||||
formRef,
|
||||
validators: fieldValidators = [],
|
||||
fwdRef,
|
||||
loading,
|
||||
error,
|
||||
secureTextEntry,
|
||||
customColor,
|
||||
marginBottom = 10,
|
||||
marginRight,
|
||||
button,
|
||||
buttonLeft,
|
||||
buttons,
|
||||
height = 45,
|
||||
fontSize = AppFontSize.sm,
|
||||
inputStyle = {},
|
||||
containerStyle = {},
|
||||
wrapperStyle = {},
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPress,
|
||||
onChangeText,
|
||||
errorStyle,
|
||||
...restProps
|
||||
}: FormInputProps) {
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [secureEntry, setSecureEntry] = useState(true);
|
||||
const [, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const form = formRef.current;
|
||||
form.registerField(name, fieldValidators);
|
||||
const unsubscribe = form.subscribe(() => {
|
||||
setVersion((v) => v + 1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
form.unregisterField(name);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formRef, name]);
|
||||
|
||||
const value = formRef.current.getValue(name);
|
||||
const fieldError = error || formRef.current.getError(name);
|
||||
|
||||
const borderColor = useMemo(() => {
|
||||
if (fieldError) return colors.error.accent;
|
||||
if (focused) return customColor || colors.selected.border;
|
||||
return colors.primary.border;
|
||||
}, [colors, customColor, fieldError, focused]);
|
||||
|
||||
const style: ViewStyle = {
|
||||
borderWidth: 1,
|
||||
borderRadius: defaultBorderRadius,
|
||||
borderColor,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: DefaultAppStyles.GAP,
|
||||
...containerStyle
|
||||
};
|
||||
|
||||
const textStyle: TextInputProps["style"] = {
|
||||
paddingHorizontal: 0,
|
||||
fontSize,
|
||||
color:
|
||||
onPress && loading ? colors.primary.accent : colors.primary.paragraph,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
paddingBottom: DefaultAppStyles.GAP_VERTICAL,
|
||||
paddingTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
fontFamily: "Inter-Regular",
|
||||
...(inputStyle as ViewStyle)
|
||||
};
|
||||
|
||||
const handleChangeText = (nextValue: string) => {
|
||||
formRef.current.setValue(name, nextValue);
|
||||
onChangeText?.(nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
importantForAccessibility="yes"
|
||||
style={{
|
||||
marginBottom,
|
||||
marginRight,
|
||||
...wrapperStyle
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
disabled={!loading}
|
||||
onPress={onPress}
|
||||
activeOpacity={1}
|
||||
style={style}
|
||||
>
|
||||
{buttonLeft}
|
||||
|
||||
<TextInput
|
||||
{...restProps}
|
||||
defaultValue={value}
|
||||
ref={fwdRef}
|
||||
editable={!loading && restProps.editable !== false}
|
||||
onChangeText={handleChangeText}
|
||||
onFocus={(e) => {
|
||||
setFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
keyboardAppearance={isDark ? "dark" : "light"}
|
||||
style={textStyle}
|
||||
secureTextEntry={secureTextEntry && secureEntry}
|
||||
placeholderTextColor={colors.primary.placeholder}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
height: 35 > height ? height : 35,
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{secureTextEntry && (
|
||||
<IconButton
|
||||
name="eye"
|
||||
size={20}
|
||||
top={10}
|
||||
bottom={10}
|
||||
onPress={() => {
|
||||
fwdRef?.current?.blur();
|
||||
setSecureEntry(!secureEntry);
|
||||
}}
|
||||
style={{
|
||||
width: 25,
|
||||
marginLeft: 5
|
||||
}}
|
||||
color={
|
||||
secureEntry ? colors.secondary.icon : colors.primary.accent
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{buttons}
|
||||
|
||||
{button && (
|
||||
<IconButton
|
||||
testID={button.testID}
|
||||
name={button.icon}
|
||||
size={button.size || AppFontSize.xl}
|
||||
top={10}
|
||||
bottom={10}
|
||||
onPress={button.onPress}
|
||||
color={button.color}
|
||||
style={{
|
||||
marginRight: -8
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{fieldError ? (
|
||||
<Paragraph
|
||||
size={AppFontSize.xs}
|
||||
style={[{ marginTop: 5, color: colors.error.icon }, errorStyle]}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{fieldError}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormInput;
|
||||
@@ -121,8 +121,8 @@ const Input = ({
|
||||
const color = error
|
||||
? colors.error.border
|
||||
: focus
|
||||
? customColor || colors.selected.border
|
||||
: colors.primary.border;
|
||||
? customColor || colors.selected.border
|
||||
: colors.primary.border;
|
||||
|
||||
const validate = async (value: string) => {
|
||||
if (!validationType) return;
|
||||
|
||||
@@ -45,11 +45,11 @@ import ExportNotesSheet from "../components/sheets/export-notes";
|
||||
import PaywallSheet from "../components/sheets/paywall";
|
||||
import PublishNoteSheet from "../components/sheets/publish-note";
|
||||
import { ReferencesList } from "../components/sheets/references";
|
||||
import { RelationsList } from "../components/sheets/relations-list/index";
|
||||
import { useSideBarDraggingStore } from "../components/side-menu/dragging-store";
|
||||
import { ButtonProps } from "../components/ui/button";
|
||||
import AddReminder from "../screens/add-reminder";
|
||||
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
|
||||
import RelationsList from "../screens/relations-list";
|
||||
import {
|
||||
eSendEvent,
|
||||
eSubscribeEvent,
|
||||
@@ -368,7 +368,14 @@ export const useActions = ({
|
||||
inputPlaceholder: strings.name(),
|
||||
defaultValue: item.title,
|
||||
positivePress: async (value) => {
|
||||
if (!value || value.trim().length === 0) return;
|
||||
if (!value || value.trim().length === 0) {
|
||||
ToastManager.error(
|
||||
new Error(strings.nameIsRequired()),
|
||||
undefined,
|
||||
"local"
|
||||
);
|
||||
return;
|
||||
}
|
||||
await db.colors.add({
|
||||
id: item.id,
|
||||
title: value
|
||||
@@ -777,6 +784,12 @@ export const useActions = ({
|
||||
const duplicateNote = async () => {
|
||||
await db.notes.duplicate(item.id);
|
||||
Navigation.queueRoutesForUpdate();
|
||||
ToastManager.show({
|
||||
heading: strings.noteDuplicated(),
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
await sleep(500);
|
||||
close();
|
||||
};
|
||||
|
||||
@@ -1040,8 +1053,9 @@ export const useActions = ({
|
||||
title: strings.dataTypesPluralCamelCase.reminder(),
|
||||
icon: "clock-outline",
|
||||
onPress: async () => {
|
||||
close();
|
||||
RelationsList.present({
|
||||
reference: item,
|
||||
item,
|
||||
referenceType: "reminder",
|
||||
relationType: "from",
|
||||
title: strings.dataTypesPluralCamelCase.reminder(),
|
||||
@@ -1058,26 +1072,6 @@ export const useActions = ({
|
||||
}
|
||||
AddReminder.present(undefined, item);
|
||||
close();
|
||||
},
|
||||
button: {
|
||||
type: "plain",
|
||||
onPress: async () => {
|
||||
if (features && !features.activeReminders.isAllowed) {
|
||||
ToastManager.show({
|
||||
type: "info",
|
||||
message: features.activeReminders.error,
|
||||
actionText: strings.upgrade(),
|
||||
func: () => {
|
||||
PaywallSheet.present(features.activeReminders);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
AddReminder.present(undefined, item);
|
||||
close();
|
||||
},
|
||||
icon: "plus",
|
||||
iconSize: 20
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -1121,8 +1115,9 @@ export const useActions = ({
|
||||
id: "add-reminder",
|
||||
title: strings.remindMe(),
|
||||
icon: "clock-plus-outline",
|
||||
onPress: () => {
|
||||
onPress: async () => {
|
||||
close();
|
||||
await sleep(100);
|
||||
AddReminder.present(undefined, item);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -95,6 +95,7 @@ import { BETA } from "../utils/constants";
|
||||
import {
|
||||
eAfterSync,
|
||||
eCloseSheet,
|
||||
eCloseSimpleDialog,
|
||||
eEditorReset,
|
||||
eLoginSessionExpired,
|
||||
eOnLoadNote,
|
||||
@@ -286,7 +287,10 @@ const onUserSubscriptionStatusChanged = async (
|
||||
subscription: subscription
|
||||
}
|
||||
});
|
||||
Walkthrough.present("prouser", false, true);
|
||||
eSendEvent(eCloseSimpleDialog);
|
||||
setTimeout(() => {
|
||||
Walkthrough.present("prouser", false, true);
|
||||
}, 500);
|
||||
}
|
||||
await PremiumService.setPremiumStatus();
|
||||
useMessageStore.getState().setAnnouncement();
|
||||
|
||||
@@ -16,28 +16,61 @@ 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 { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { db } from "../common/database";
|
||||
import { eSubscribeEvent, eUnSubscribeEvent } from "../services/event-manager";
|
||||
import Navigation from "../services/navigation";
|
||||
import { eGroupOptionsUpdated } from "../utils/events";
|
||||
import { useSettingStore } from "../stores/use-setting-store";
|
||||
import { GroupingByIdKey, GroupingKey, GroupOptions } from "@notesnook/core";
|
||||
|
||||
export function useGroupOptions(type: any) {
|
||||
export function getGroupOptions(
|
||||
groupingKey: GroupingKey,
|
||||
id?: string,
|
||||
type?: GroupingByIdKey
|
||||
) {
|
||||
return id && type
|
||||
? db.settings?.getGroupOptionsById(id, type)
|
||||
: db.settings?.getGroupOptions(groupingKey);
|
||||
}
|
||||
|
||||
export function setGroupOptionsById(
|
||||
groupingKey: GroupingKey,
|
||||
groupOptions: GroupOptions,
|
||||
id?: string,
|
||||
type?: GroupingByIdKey
|
||||
) {
|
||||
return id && type
|
||||
? db.settings?.setGroupOptionsById(id, type, groupOptions)
|
||||
: db.settings?.setGroupOptions(groupingKey, groupOptions);
|
||||
}
|
||||
|
||||
export function useGroupOptions(
|
||||
groupingKey: GroupingKey,
|
||||
id?: string,
|
||||
type?: GroupingByIdKey
|
||||
) {
|
||||
const appLoading = useSettingStore((state) => state.isAppLoading);
|
||||
const [groupOptions, setGroupOptions] = useState(
|
||||
db.settings?.getGroupOptions(type)
|
||||
getGroupOptions(groupingKey, id, type)
|
||||
);
|
||||
console.log(groupingKey, id, type, groupOptions, "options");
|
||||
const groupOptionsRef = useRef(groupOptions);
|
||||
groupOptionsRef.current = groupOptions;
|
||||
|
||||
useEffect(() => {
|
||||
const onUpdate = (groupType: string) => {
|
||||
if (groupType !== type) return;
|
||||
const options = db.settings?.getGroupOptions(type) as any;
|
||||
const onUpdate = (_groupingKey: string, _id?: string, _type?: string) => {
|
||||
if (_groupingKey !== groupingKey) return;
|
||||
if (_id && _type && _id !== id && _type !== type) return;
|
||||
|
||||
const options = getGroupOptions(groupingKey, id, type);
|
||||
if (!options) return;
|
||||
if (
|
||||
groupOptions?.groupBy !== options.groupBy ||
|
||||
groupOptions?.sortBy !== options.sortBy ||
|
||||
groupOptions?.sortDirection !== groupOptions?.sortDirection
|
||||
groupOptionsRef.current?.groupBy !== options.groupBy ||
|
||||
groupOptionsRef.current?.sortBy !== options.sortBy ||
|
||||
groupOptionsRef.current?.sortDirection !== options?.sortDirection
|
||||
) {
|
||||
console.log("onUpdate", _id, _type);
|
||||
setGroupOptions({ ...options });
|
||||
Navigation.queueRoutesForUpdate();
|
||||
}
|
||||
@@ -46,13 +79,13 @@ export function useGroupOptions(type: any) {
|
||||
eSubscribeEvent(eGroupOptionsUpdated, onUpdate);
|
||||
|
||||
if (!appLoading) {
|
||||
onUpdate(type);
|
||||
onUpdate(groupingKey);
|
||||
}
|
||||
|
||||
return () => {
|
||||
eUnSubscribeEvent(eGroupOptionsUpdated, onUpdate);
|
||||
};
|
||||
}, [type, groupOptions, appLoading]);
|
||||
}, [groupingKey, appLoading, id, type]);
|
||||
|
||||
return groupOptions;
|
||||
}
|
||||
|
||||
@@ -20,14 +20,27 @@ import { ItemType } from "@notesnook/core";
|
||||
import { useSettingStore } from "../stores/use-setting-store";
|
||||
|
||||
export function useIsCompactModeEnabled(dataType: ItemType) {
|
||||
const [notebooksListMode, notesListMode] = useSettingStore((state) => [
|
||||
state.settings.notebooksListMode,
|
||||
state.settings.notesListMode
|
||||
]);
|
||||
const [notebooksListMode, notesListMode, searchListMode] = useSettingStore(
|
||||
(state) => [
|
||||
state.settings.notebooksListMode,
|
||||
state.settings.notesListMode,
|
||||
state.settings.searchListMode
|
||||
]
|
||||
);
|
||||
|
||||
if (dataType !== "note" && dataType !== "notebook") return false;
|
||||
if (
|
||||
dataType !== "note" &&
|
||||
dataType !== "notebook" &&
|
||||
dataType !== "searchResult"
|
||||
)
|
||||
return false;
|
||||
|
||||
const listMode = dataType === "notebook" ? notebooksListMode : notesListMode;
|
||||
const listMode =
|
||||
dataType === "notebook"
|
||||
? notebooksListMode
|
||||
: dataType === "searchResult"
|
||||
? searchListMode
|
||||
: notesListMode;
|
||||
|
||||
return listMode === "compact";
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { Plan, SubscriptionPlan } from "@notesnook/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useAsync } from "react-async-hook";
|
||||
import { Platform } from "react-native";
|
||||
import Config from "react-native-config";
|
||||
@@ -181,6 +181,8 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
|
||||
const [cancelPromo, setCancelPromo] = useState(false);
|
||||
const [userCanRequestTrial, setUserCanRequestTrial] = useState(false);
|
||||
const [webPricingPlans, setWebPricingPlans] = useState<Plan[]>([]);
|
||||
const plansRef = useRef(plans);
|
||||
plansRef.current = plans;
|
||||
|
||||
const getProduct = React.useCallback(
|
||||
(planId: string, skuId: string) => {
|
||||
@@ -190,11 +192,11 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
|
||||
);
|
||||
|
||||
return (
|
||||
plans.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
|
||||
plans.find((p) => p.id === planId)?.products?.[skuId]
|
||||
plansRef.current.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
|
||||
plansRef.current.find((p) => p.id === planId)?.products?.[skuId]
|
||||
);
|
||||
},
|
||||
[plans, webPricingPlans]
|
||||
[webPricingPlans]
|
||||
);
|
||||
|
||||
const getProductAndroid = (planId: string, skuId: string) => {
|
||||
@@ -253,35 +255,41 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
|
||||
|
||||
useEffect(() => {
|
||||
const loadPlans = async () => {
|
||||
const items = await PremiumService.loadProductsAndSubs();
|
||||
pricingPlans.forEach((plan) => {
|
||||
plan.subscriptions = {};
|
||||
plan.products = {};
|
||||
plan.subscriptionSkuList.forEach((sku) => {
|
||||
if (!plan.subscriptions) plan.subscriptions = {};
|
||||
plan.subscriptions[sku] = items.subs.find((p) => p.productId === sku);
|
||||
try {
|
||||
const items = await PremiumService.loadProductsAndSubs();
|
||||
pricingPlans.forEach((plan) => {
|
||||
plan.subscriptions = {};
|
||||
plan.products = {};
|
||||
plan.subscriptionSkuList.forEach((sku) => {
|
||||
if (!plan.subscriptions) plan.subscriptions = {};
|
||||
plan.subscriptions[sku] = items.subs.find(
|
||||
(p) => p.productId === sku
|
||||
);
|
||||
});
|
||||
plan.productSkuList.forEach((sku) => {
|
||||
if (!plan.products) plan.products = {};
|
||||
plan.products[sku] = items.products.find(
|
||||
(p) => p.productId === sku
|
||||
);
|
||||
});
|
||||
});
|
||||
plan.productSkuList.forEach((sku) => {
|
||||
if (!plan.products) plan.products = {};
|
||||
plan.products[sku] = items.products.find((p) => p.productId === sku);
|
||||
});
|
||||
});
|
||||
setPlans([...pricingPlans]);
|
||||
setUserCanRequestTrial(hasTrialOffer());
|
||||
if (
|
||||
Config.GITHUB_RELEASE === "true" &&
|
||||
!SettingsService.getProperty("serverUrls")
|
||||
) {
|
||||
try {
|
||||
const products = WebPlanCache || (await db.pricing.products());
|
||||
WebPlanCache = products;
|
||||
setWebPricingPlans(products);
|
||||
} catch (e) {
|
||||
/**
|
||||
setPlans([...pricingPlans]);
|
||||
setUserCanRequestTrial(hasTrialOffer());
|
||||
if (
|
||||
Config.GITHUB_RELEASE === "true" &&
|
||||
!SettingsService.getProperty("serverUrls")
|
||||
) {
|
||||
try {
|
||||
const products = WebPlanCache || (await db.pricing.products());
|
||||
WebPlanCache = products;
|
||||
setWebPricingPlans(products);
|
||||
} catch (e) {
|
||||
/**
|
||||
empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoadingPlans(false);
|
||||
setLoadingPlans(false);
|
||||
} catch (e) {}
|
||||
};
|
||||
loadPlans();
|
||||
}, [options?.promoOffer, cancelPromo, hasTrialOffer]);
|
||||
|
||||
@@ -32,6 +32,8 @@ const useTimer = (initialId?: string) => {
|
||||
const [id, setId] = useState(initialId);
|
||||
const [seconds, setSeconds] = useState(getSecondsLeft(id));
|
||||
const interval = useRef<NodeJS.Timeout>(undefined);
|
||||
const secondsRef = useRef(seconds);
|
||||
secondsRef.current = seconds;
|
||||
|
||||
const start = (sec: number, currentId = id) => {
|
||||
if (!currentId) return;
|
||||
@@ -59,7 +61,7 @@ const useTimer = (initialId?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { seconds, setId, start, reset };
|
||||
return { seconds, setId, start, reset, secondsRef: secondsRef };
|
||||
};
|
||||
|
||||
export default useTimer;
|
||||
|
||||
@@ -279,6 +279,7 @@ let MoveNotes: any = null;
|
||||
let Settings: any = null;
|
||||
let ManageTags: any = null;
|
||||
let AddReminder: any = null;
|
||||
let RelationsList: any = null;
|
||||
let PayWall: any = null;
|
||||
let Wrapped: any = null;
|
||||
export const RootNavigation = () => {
|
||||
@@ -385,6 +386,15 @@ export const RootNavigation = () => {
|
||||
return AddReminder;
|
||||
}}
|
||||
/>
|
||||
|
||||
<RootStack.Screen
|
||||
name="RelationsList"
|
||||
getComponent={() => {
|
||||
RelationsList =
|
||||
RelationsList || require("../screens/relations-list").default;
|
||||
return RelationsList;
|
||||
}}
|
||||
/>
|
||||
<RootStack.Screen
|
||||
name="PayWall"
|
||||
getComponent={() => {
|
||||
|
||||
@@ -35,7 +35,6 @@ import { db } from "../../common/database";
|
||||
import { Dialog } from "../../components/dialog";
|
||||
import { Header } from "../../components/header";
|
||||
import { Button } from "../../components/ui/button";
|
||||
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";
|
||||
@@ -59,6 +58,11 @@ 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";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../components/ui/input/form-input";
|
||||
import AppIcon from "../../components/ui/AppIcon";
|
||||
|
||||
const ReminderModes =
|
||||
Platform.OS === "ios"
|
||||
@@ -91,7 +95,17 @@ const ReminderNotificationModes = {
|
||||
|
||||
export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
const { reminder, reference } = props.route.params;
|
||||
useNavigationFocus(props.navigation, { focusOnInit: true });
|
||||
useNavigationFocus(props.navigation, {
|
||||
focusOnInit: true,
|
||||
onFocus: () => {
|
||||
if (!props.route.params?.reminder) {
|
||||
setTimeout(() => {
|
||||
titleRef.current?.focus();
|
||||
}, 200);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const weekFormat = useSettingStore((state) => state.weekFormat);
|
||||
const [reminderMode, setReminderMode] = useState<Reminder["mode"]>(
|
||||
@@ -113,7 +127,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
const [repeatFrequency, setRepeatFrequency] = useState(1);
|
||||
const referencedItem = reference ? (reference as Note) : null;
|
||||
const recurringReminderFeature = useIsFeatureAvailable("recurringReminders");
|
||||
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
title: reminder?.title || referencedItem?.title || "",
|
||||
description: reminder?.description || referencedItem?.headline || ""
|
||||
})
|
||||
);
|
||||
const title = useRef<string | undefined>(
|
||||
!reminder ? referencedItem?.title : reminder?.title
|
||||
);
|
||||
@@ -132,6 +151,8 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
: null,
|
||||
[reminder?.id]
|
||||
);
|
||||
const [dateError, setDateError] = useState<string>();
|
||||
const [selectDayError, setSelectDayError] = useState<string>();
|
||||
|
||||
const showDatePicker = () => {
|
||||
setDatePickerVisibility(true);
|
||||
@@ -143,6 +164,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
|
||||
const handleConfirm = (date: Date) => {
|
||||
timer.current = setTimeout(() => {
|
||||
setDateError(undefined);
|
||||
hideDatePicker();
|
||||
setDate(date);
|
||||
}, 10);
|
||||
@@ -172,26 +194,27 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
|
||||
async function saveReminder() {
|
||||
try {
|
||||
if (!(await Notifications.checkAndRequestPermissions(true)))
|
||||
throw new Error(strings.noNotificationPermission());
|
||||
if (!date && reminderMode !== ReminderModes.Permanent) return;
|
||||
if (!formRef.current.validate()) return;
|
||||
if (date.getTime() < Date.now() && reminderMode === "once") {
|
||||
setDateError(strings.dateError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
reminderMode === ReminderModes.Repeat &&
|
||||
recurringMode !== "day" &&
|
||||
recurringMode !== "year" &&
|
||||
selectedDays.length === 0
|
||||
)
|
||||
throw new Error(strings.selectDayError());
|
||||
|
||||
if (!title.current) throw new Error(strings.setTitleError());
|
||||
if (
|
||||
date.getTime() < Date.now() &&
|
||||
reminderMode === "once" &&
|
||||
!props.route.params.reminder
|
||||
) {
|
||||
throw new Error(strings.dateError());
|
||||
setSelectDayError(strings.selectDayError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!date && reminderMode !== ReminderModes.Permanent) return;
|
||||
|
||||
if (!(await Notifications.checkAndRequestPermissions(true)))
|
||||
throw new Error(strings.noNotificationPermission());
|
||||
|
||||
date.setSeconds(0, 0);
|
||||
|
||||
const reminderId = await db.reminders?.add({
|
||||
@@ -261,21 +284,26 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Input
|
||||
<FormInput
|
||||
name="title"
|
||||
validators={[validators.required(strings.titleIsRequired())]}
|
||||
formRef={formRef}
|
||||
fwdRef={titleRef}
|
||||
defaultValue={reminder?.title || referencedItem?.title}
|
||||
placeholder={strings.remindeMeOf()}
|
||||
onChangeText={(text) => (title.current = text)}
|
||||
autoFocus
|
||||
wrapperStyle={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
onSubmit={() => {
|
||||
onSubmitEditing={() => {
|
||||
descriptionRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="description"
|
||||
validators={[]}
|
||||
formRef={formRef}
|
||||
defaultValue={
|
||||
reminder ? reminder?.description : referencedItem?.headline
|
||||
}
|
||||
@@ -461,6 +489,22 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
{selectDayError ? (
|
||||
<Paragraph
|
||||
size={AppFontSize.xs}
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
color: colors.error.icon
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{selectDayError}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -476,16 +520,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
<DateTimePickerModal
|
||||
isVisible={isDatePickerVisible}
|
||||
mode="datetime"
|
||||
minimumDate={
|
||||
reminderMode === "once" ? dayjs().toDate() : new Date(0)
|
||||
}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={hideDatePicker}
|
||||
isDarkModeEnabled={isDark}
|
||||
firstDayOfWeek={weekFormat === "Mon" ? 1 : 0}
|
||||
is24Hour={db.settings.getTimeFormat() === "24-hour"}
|
||||
date={date || new Date(Date.now())}
|
||||
themeVariant={isDark ? "dark" : "light"}
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
date={date}
|
||||
minimumDate={
|
||||
reminderMode === "once" ? dayjs().toDate() : new Date(0)
|
||||
}
|
||||
maximumDate={dayjs(date).add(3, "months").toDate()}
|
||||
onDateChange={handleConfirm}
|
||||
theme={isDark ? "dark" : "light"}
|
||||
@@ -519,6 +570,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dateError ? (
|
||||
<Paragraph
|
||||
size={AppFontSize.xs}
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
color: colors.error.icon
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{dateError}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export const Archive = ({ navigation, route }: NavigationProps<"Archive">) => {
|
||||
onRefresh={() => {
|
||||
refresh();
|
||||
}}
|
||||
groupType="archive"
|
||||
renderedInRoute="Archive"
|
||||
loading={loading}
|
||||
placeholder={{
|
||||
|
||||
@@ -46,7 +46,6 @@ import EditorTabs from "../../../components/sheets/editor-tabs";
|
||||
import { Issue } from "../../../components/sheets/github/issue";
|
||||
import LinkNote from "../../../components/sheets/link-note";
|
||||
import PaywallSheet from "../../../components/sheets/paywall";
|
||||
import { RelationsList } from "../../../components/sheets/relations-list";
|
||||
import TableOfContents from "../../../components/sheets/toc";
|
||||
import { DDS } from "../../../services/device-detection";
|
||||
import {
|
||||
@@ -80,6 +79,7 @@ import { fluidTabsRef } from "../../../utils/global-refs";
|
||||
import { sleep } from "../../../utils/time";
|
||||
import AddReminder from "../../add-reminder";
|
||||
import ManageTags from "../../manage-tags";
|
||||
import RelationsList from "../../relations-list";
|
||||
import { useDragState } from "../../settings/editor/state";
|
||||
import { EditorMessage, EditorProps, useEditorType } from "./types";
|
||||
import { useTabStore } from "./use-tab-store";
|
||||
@@ -466,7 +466,7 @@ export const useEditorEvents = (
|
||||
const note = await db.notes.note(noteId);
|
||||
if (!note) return;
|
||||
RelationsList.present({
|
||||
reference: note as any,
|
||||
item: note as any,
|
||||
referenceType: "reminder",
|
||||
relationType: "from",
|
||||
title: strings.dataTypesPluralCamelCase.reminder(),
|
||||
|
||||
@@ -70,6 +70,7 @@ export const Favorites = ({
|
||||
<List
|
||||
data={favorites}
|
||||
dataType="note"
|
||||
groupType="favorites"
|
||||
onRefresh={() => {
|
||||
refresh();
|
||||
}}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const Home = ({ navigation, route }: NavigationProps<"Notes">) => {
|
||||
<List
|
||||
data={notes}
|
||||
dataType="note"
|
||||
groupType="home"
|
||||
renderedInRoute={route.name}
|
||||
loading={loading || !isFocused}
|
||||
headerTitle={strings.routes[route.name]()}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { View } from "react-native";
|
||||
import { Notebooks } from "../../components/sheets/notebooks";
|
||||
import { useSettingStore } from "../../stores/use-setting-store";
|
||||
import { rootNavigatorRef } from "../../utils/global-refs";
|
||||
import { getGroupOptions } from "../../hooks/use-group-options";
|
||||
|
||||
const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
|
||||
const [notes, setNotes] = useState<VirtualizedGrouping<Note>>();
|
||||
@@ -120,7 +121,9 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
|
||||
params.current.id = notebook.id;
|
||||
const notes = await db.relations
|
||||
.from(notebook, "note")
|
||||
.selector.grouped(db.settings.getGroupOptions("notes"));
|
||||
.selector.grouped(
|
||||
getGroupOptions("notes", notebook.id, "notebook")
|
||||
);
|
||||
setNotes(notes);
|
||||
await notes.item(0, resolveItems);
|
||||
syncWithNavigation();
|
||||
@@ -185,10 +188,12 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
|
||||
<List
|
||||
data={notes}
|
||||
dataType="note"
|
||||
groupType="notes"
|
||||
onRefresh={() => {
|
||||
onRequestUpdate();
|
||||
}}
|
||||
id={params.current?.id}
|
||||
type="notebook"
|
||||
renderedInRoute="Notebook"
|
||||
headerTitle={notebook?.title}
|
||||
loading={loading}
|
||||
|
||||
@@ -26,6 +26,7 @@ import useNavigationStore, {
|
||||
NotesScreenParams
|
||||
} from "../../stores/use-navigation-store";
|
||||
import { PLACEHOLDER_DATA, openEditor } from "./common";
|
||||
import { getGroupOptions } from "../../hooks/use-group-options";
|
||||
export const ColoredNotes = ({
|
||||
navigation,
|
||||
route
|
||||
@@ -52,7 +53,7 @@ ColoredNotes.get = async (params: NotesScreenParams, grouped = true) => {
|
||||
|
||||
return await db.relations
|
||||
.from({ id: params.id, type: "color" }, "note")
|
||||
.selector.grouped(db.settings.getGroupOptions("notes"));
|
||||
.selector.grouped(getGroupOptions("notes", params.id, "color"));
|
||||
};
|
||||
|
||||
ColoredNotes.navigate = (item: Color, canGoBack: boolean) => {
|
||||
|
||||
@@ -18,8 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { resolveItems } from "@notesnook/common";
|
||||
import { Tag, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { Color, Note } from "@notesnook/core";
|
||||
import { Color, Note, Tag, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { db } from "../../common/database";
|
||||
import { FloatingButton } from "../../components/container/floating-button";
|
||||
@@ -39,10 +39,9 @@ import useNavigationStore, {
|
||||
NotesScreenParams,
|
||||
RouteName
|
||||
} from "../../stores/use-navigation-store";
|
||||
import { setOnFirstSave } from "./common";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useSettingStore } from "../../stores/use-setting-store";
|
||||
import { rootNavigatorRef } from "../../utils/global-refs";
|
||||
import { setOnFirstSave } from "./common";
|
||||
|
||||
export interface RouteProps<T extends RouteName> extends NavigationProps<T> {
|
||||
get: (
|
||||
@@ -172,7 +171,7 @@ const NotesPage = ({
|
||||
if (loadingNotes) {
|
||||
onRequestUpdate(params.current);
|
||||
}
|
||||
}, [loadingNotes, get, isAppLoading]);
|
||||
}, [loadingNotes, get, isAppLoading, onRequestUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
eSubscribeEvent(route.name, onRequestUpdate);
|
||||
@@ -193,11 +192,14 @@ const NotesPage = ({
|
||||
hasSearch={true}
|
||||
id={route.name === "Monographs" ? "Monographs" : params?.current?.id}
|
||||
onSearch={() => {
|
||||
if (!item) return;
|
||||
if (route.name !== "Monographs" && !item) return;
|
||||
|
||||
const selector =
|
||||
route.name === "Monographs"
|
||||
? db.monographs.all
|
||||
: db.relations.from(item, "note").selector;
|
||||
: item && db.relations.from(item, "note").selector;
|
||||
|
||||
if (!selector) return;
|
||||
|
||||
Navigation.push("Search", {
|
||||
placeholder: strings.searchInRoute(title || route.name),
|
||||
@@ -214,10 +216,12 @@ const NotesPage = ({
|
||||
<List
|
||||
data={notes}
|
||||
dataType="note"
|
||||
groupType="notes"
|
||||
onRefresh={onRequestUpdate}
|
||||
loading={false}
|
||||
renderedInRoute={route.name}
|
||||
id={params.current?.id}
|
||||
type={params.current?.item?.type}
|
||||
headerTitle={title || "Monographs"}
|
||||
customAccentColor={accentColor}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -26,6 +26,7 @@ import useNavigationStore, {
|
||||
NotesScreenParams
|
||||
} from "../../stores/use-navigation-store";
|
||||
import { PLACEHOLDER_DATA, openEditor } from "./common";
|
||||
import { getGroupOptions } from "../../hooks/use-group-options";
|
||||
|
||||
export const TaggedNotes = ({
|
||||
navigation,
|
||||
@@ -53,7 +54,7 @@ TaggedNotes.get = async (params: NotesScreenParams, grouped = true) => {
|
||||
|
||||
return await db.relations
|
||||
.from({ id: params.id, type: "tag" }, "note")
|
||||
.selector.grouped(db.settings.getGroupOptions("notes"));
|
||||
.selector.grouped(getGroupOptions("notes", params.id, "tag"));
|
||||
};
|
||||
|
||||
TaggedNotes.navigate = (item: Tag, canGoBack?: boolean) => {
|
||||
|
||||
142
apps/mobile/app/screens/relations-list/index.tsx
Normal file
142
apps/mobile/app/screens/relations-list/index.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
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, ItemReference, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../common/database";
|
||||
import { Header } from "../../components/header";
|
||||
import List from "../../components/list";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import Paragraph from "../../components/ui/typography/paragraph";
|
||||
import Navigation, { NavigationProps } from "../../services/navigation";
|
||||
import { useRelationStore } from "../../stores/use-relation-store";
|
||||
import { AppFontSize } from "../../utils/size";
|
||||
|
||||
type RelationsListProps = {
|
||||
item: Item;
|
||||
referenceType: "notebook" | "tag" | "reminder" | "note";
|
||||
relationType: "to" | "from";
|
||||
title: string;
|
||||
onAdd?: () => void;
|
||||
};
|
||||
|
||||
const IconsByType = {
|
||||
reminder: "bell"
|
||||
};
|
||||
|
||||
function RelationsList(props: NavigationProps<"RelationsList">) {
|
||||
const { item, referenceType, relationType, title, onAdd } = props.route
|
||||
.params as RelationsListProps;
|
||||
const updater = useRelationStore((state) => state.updater);
|
||||
const { colors } = useThemeColors();
|
||||
const [items, setItems] = useState<VirtualizedGrouping<Item>>();
|
||||
const hasNoRelations = !items || items?.placeholders?.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
db.relations?.[relationType]?.(
|
||||
{ id: item?.id, type: item?.type } as ItemReference,
|
||||
referenceType
|
||||
)
|
||||
.selector.sorted({
|
||||
sortBy: "dateEdited",
|
||||
sortDirection: "desc"
|
||||
})
|
||||
.then((grouped) => {
|
||||
setTimeout(() => {
|
||||
setItems(grouped);
|
||||
}, 300);
|
||||
});
|
||||
}, [relationType, referenceType, item?.id, item?.type, updater]);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: colors.primary.background,
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<Header title={title} canGoBack />
|
||||
<View style={{ flex: 1 }}>
|
||||
{hasNoRelations ? (
|
||||
<View
|
||||
style={{
|
||||
height: "85%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={IconsByType[referenceType as keyof typeof IconsByType]}
|
||||
size={60}
|
||||
color={colors.primary.icon}
|
||||
/>
|
||||
<Paragraph>{strings.noLinksFound()}</Paragraph>
|
||||
<Button
|
||||
onPress={onAdd}
|
||||
fontSize={AppFontSize.sm}
|
||||
type="inverted"
|
||||
icon="plus"
|
||||
title={strings.addItem(referenceType)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<List
|
||||
data={items}
|
||||
loading={false}
|
||||
groupType={
|
||||
referenceType === "note"
|
||||
? "notes"
|
||||
: referenceType === "tag"
|
||||
? "tags"
|
||||
: referenceType === "notebook"
|
||||
? "notebooks"
|
||||
: referenceType === "reminder"
|
||||
? "reminders"
|
||||
: "notes"
|
||||
}
|
||||
dataType={referenceType}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
RelationsList.present = ({
|
||||
item,
|
||||
relationType,
|
||||
referenceType,
|
||||
title,
|
||||
onAdd
|
||||
}: RelationsListProps) => {
|
||||
Navigation.navigate("RelationsList", {
|
||||
item,
|
||||
relationType,
|
||||
referenceType,
|
||||
title,
|
||||
onAdd
|
||||
});
|
||||
};
|
||||
|
||||
export default RelationsList;
|
||||
@@ -90,6 +90,7 @@ export const Reminders = ({
|
||||
<List
|
||||
data={reminders}
|
||||
dataType="reminder"
|
||||
groupType="reminders"
|
||||
headerTitle={strings.routes[route.name]()}
|
||||
renderedInRoute="Reminders"
|
||||
loading={loading}
|
||||
@@ -98,9 +99,8 @@ export const Reminders = ({
|
||||
paragraph: strings.remindersEmpty(),
|
||||
button: strings.setReminder(),
|
||||
action: async () => {
|
||||
const reminderFeature = await isFeatureAvailable(
|
||||
"activeReminders"
|
||||
);
|
||||
const reminderFeature =
|
||||
await isFeatureAvailable("activeReminders");
|
||||
if (!reminderFeature.isAllowed) {
|
||||
ToastManager.show({
|
||||
type: "info",
|
||||
|
||||
@@ -154,6 +154,7 @@ export const Search = ({ route, navigation }: NavigationProps<"Search">) => {
|
||||
data={results}
|
||||
dataType={route.params?.type}
|
||||
renderedInRoute={route.name}
|
||||
groupType="search"
|
||||
loading={loading}
|
||||
placeholder={{
|
||||
title: route.name,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { sanitizeFilename, useIsFeatureAvailable } from "@notesnook/common";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors, VariantsWithStaticColors } from "@notesnook/theme";
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import isMobilePhone from "validator/lib/isMobilePhone";
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
@@ -28,7 +29,13 @@ import React, {
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { ActivityIndicator, Linking, Platform, View } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
Platform,
|
||||
TextInput,
|
||||
View
|
||||
} from "react-native";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { FlatList } from "react-native-gesture-handler";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
@@ -37,7 +44,10 @@ import filesystem from "../../common/filesystem";
|
||||
import DialogHeader from "../../components/dialog/dialog-header";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { IconButton } from "../../components/ui/icon-button";
|
||||
import Input from "../../components/ui/input";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../components/ui/input/form-input";
|
||||
import { Pressable } from "../../components/ui/pressable";
|
||||
import Seperator from "../../components/ui/seperator";
|
||||
import { SvgView } from "../../components/ui/svg";
|
||||
@@ -183,27 +193,60 @@ export const MFASetup = ({
|
||||
}: MFAStepProps) => {
|
||||
const { colors } = useThemeColors();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const methodId = method?.id;
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
target: "",
|
||||
code: ""
|
||||
})
|
||||
);
|
||||
const targetInputRef = useRef<TextInput>(null);
|
||||
const codeInputRef = useRef<TextInput>(null);
|
||||
const [authenticatorDetails, setAuthenticatorDetails] = useState({
|
||||
sharedKey: null,
|
||||
authenticatorUri: null
|
||||
});
|
||||
const code = useRef<string>(undefined);
|
||||
const phoneNumber = useRef<string>(undefined);
|
||||
const { seconds, setId, start } = useTimer(method?.id);
|
||||
|
||||
const [loading, setLoading] = useState(method?.id === "app" ? true : false);
|
||||
const [enabling, setEnabling] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [generalError, setGeneralError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (method?.id === "app") {
|
||||
db.mfa?.setup("app").then((data) => {
|
||||
setAuthenticatorDetails(data);
|
||||
setLoading(false);
|
||||
});
|
||||
if (methodId === "app") {
|
||||
setLoading(true);
|
||||
db.mfa
|
||||
?.setup("app")
|
||||
.then((data) => {
|
||||
setAuthenticatorDetails(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setLoading(false);
|
||||
setGeneralError(error.message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [method?.id]);
|
||||
|
||||
setLoading(false);
|
||||
}, [methodId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!methodId) return;
|
||||
|
||||
formRef.current.clearErrors();
|
||||
formRef.current.setValue(
|
||||
"target",
|
||||
methodId === "email"
|
||||
? user?.email || ""
|
||||
: methodId === "app"
|
||||
? authenticatorDetails.sharedKey || ""
|
||||
: formRef.current.getValue("target")
|
||||
);
|
||||
formRef.current.setValue("code", "");
|
||||
setGeneralError(undefined);
|
||||
}, [authenticatorDetails.sharedKey, methodId, user?.email]);
|
||||
|
||||
const codeHelpText = {
|
||||
app: "After putting the above code in authenticator app, the app will display a code that you can enter below.",
|
||||
@@ -212,15 +255,43 @@ export const MFASetup = ({
|
||||
"You will receive a 2FA code on your email address which you can enter below"
|
||||
};
|
||||
|
||||
const targetValidators =
|
||||
method?.id === "sms"
|
||||
? [
|
||||
validators.required(strings.phoneNumberNotEntered()),
|
||||
(value: string) =>
|
||||
isMobilePhone(value, "any", {
|
||||
strictMode: true
|
||||
})
|
||||
? undefined
|
||||
: strings.enterValidPhone()
|
||||
]
|
||||
: method?.id === "email"
|
||||
? [
|
||||
validators.required(strings.emailRequired()),
|
||||
validators.email(strings.enterValidEmail())
|
||||
]
|
||||
: [];
|
||||
|
||||
const codeValidators = [
|
||||
validators.required(strings.enterSixDigitCode()),
|
||||
(value: string) =>
|
||||
/^\d{6}$/.test(value.trim()) ? undefined : strings.enterSixDigitCode()
|
||||
];
|
||||
|
||||
const onNext = async () => {
|
||||
if (!code.current || code.current.length !== 6) return;
|
||||
if (formRef.current.validateField("code")) return;
|
||||
|
||||
try {
|
||||
if (!method) return;
|
||||
const code = formRef.current.getValue("code").trim();
|
||||
|
||||
setGeneralError(undefined);
|
||||
setEnabling(true);
|
||||
if (recovery) {
|
||||
await db.mfa.enableFallback(method.id, code.current);
|
||||
await db.mfa.enableFallback(method.id, code);
|
||||
} else {
|
||||
await db.mfa.enable(method.id, code.current);
|
||||
await db.mfa.enable(method.id, code);
|
||||
}
|
||||
|
||||
const user = await db.user.fetchUser();
|
||||
@@ -229,14 +300,18 @@ export const MFASetup = ({
|
||||
setEnabling(false);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
ToastManager.error(error, "Error submitting 2fa code");
|
||||
formRef.current.setError("code", error.message);
|
||||
setEnabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSendCode = async () => {
|
||||
if (error) return;
|
||||
if (!method || sending) return;
|
||||
|
||||
if (method.id !== "app" && formRef.current.validateField("target")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method.id === "app" && authenticatorDetails.sharedKey) {
|
||||
Clipboard.setString(authenticatorDetails.sharedKey);
|
||||
if (authenticatorDetails.authenticatorUri) {
|
||||
@@ -254,30 +329,37 @@ export const MFASetup = ({
|
||||
}
|
||||
|
||||
try {
|
||||
if (seconds) throw new Error(strings.resendCodeWait());
|
||||
if (method.id === "sms" && !phoneNumber.current)
|
||||
throw new Error(strings.phoneNumberNotEntered());
|
||||
const target = formRef.current.getValue("target").trim();
|
||||
|
||||
setGeneralError(undefined);
|
||||
if (seconds) {
|
||||
setGeneralError(strings.resendCodeWait());
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
await db.mfa.setup(method?.id, phoneNumber.current);
|
||||
await db.mfa.setup(method.id, method.id === "sms" ? target : undefined);
|
||||
|
||||
if (method.id === "sms") {
|
||||
setId(method.id + phoneNumber.current);
|
||||
setId(method.id + target);
|
||||
}
|
||||
await sleep(300);
|
||||
start(
|
||||
60,
|
||||
method.id === "sms" ? method.id + phoneNumber.current : method.id
|
||||
);
|
||||
start(60, method.id === "sms" ? method.id + target : method.id);
|
||||
setSending(false);
|
||||
ToastManager.show({
|
||||
heading: strings["2faCodeSentVia"](method.id),
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
codeInputRef.current?.focus();
|
||||
} catch (e) {
|
||||
setSending(false);
|
||||
const error = e as Error;
|
||||
ToastManager.error(error, strings.errorSend2fa());
|
||||
if (method.id === "sms" || method.id === "email") {
|
||||
formRef.current.setError("target", error.message);
|
||||
} else {
|
||||
setGeneralError(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -316,55 +398,54 @@ export const MFASetup = ({
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
loading={method?.id !== "sms" ? true : false}
|
||||
value={
|
||||
method?.id === "email"
|
||||
? user?.email
|
||||
: method?.id === "app"
|
||||
? authenticatorDetails?.sharedKey || ""
|
||||
: undefined
|
||||
<FormInput
|
||||
key={`${method.id}-${authenticatorDetails.sharedKey || user?.email || ""}`}
|
||||
name="target"
|
||||
formRef={formRef}
|
||||
fwdRef={targetInputRef}
|
||||
loading={method?.id !== "sms"}
|
||||
editable={method.id === "sms"}
|
||||
defaultValue={
|
||||
method.id === "email"
|
||||
? user?.email || ""
|
||||
: method.id === "app"
|
||||
? authenticatorDetails.sharedKey || ""
|
||||
: undefined
|
||||
}
|
||||
multiline={method.id === "app"}
|
||||
onChangeText={(value) => {
|
||||
phoneNumber.current = value;
|
||||
onChangeText={() => {
|
||||
setGeneralError(undefined);
|
||||
}}
|
||||
placeholder={
|
||||
method?.id === "email"
|
||||
method.id === "email"
|
||||
? strings.enterEmailAddress()
|
||||
: "+1234567890"
|
||||
}
|
||||
onSubmit={() => {
|
||||
onSendCode();
|
||||
}}
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
validationType={method?.id === "email" ? "email" : "phonenumber"}
|
||||
onSubmitEditing={onSendCode}
|
||||
validators={targetValidators}
|
||||
keyboardType={
|
||||
method.id == "email" ? "email-address" : "phone-pad"
|
||||
}
|
||||
errorMessage={
|
||||
method?.id === "email"
|
||||
? strings.enterValidEmail()
|
||||
: strings.enterValidPhone()
|
||||
method.id === "email" ? "email-address" : "phone-pad"
|
||||
}
|
||||
buttons={
|
||||
error ? null : (
|
||||
<Button
|
||||
onPress={onSendCode}
|
||||
loading={sending}
|
||||
title={
|
||||
sending
|
||||
? null
|
||||
: method.id === "app"
|
||||
<Button
|
||||
onPress={onSendCode}
|
||||
loading={sending}
|
||||
style={{
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 0
|
||||
}}
|
||||
title={
|
||||
sending
|
||||
? null
|
||||
: method.id === "app"
|
||||
? strings.copy()
|
||||
: `${
|
||||
seconds
|
||||
? strings.resendCode(seconds as number)
|
||||
: strings.sendCode()
|
||||
}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -373,13 +454,22 @@ export const MFASetup = ({
|
||||
</Heading>
|
||||
<Paragraph>{codeHelpText[method?.id]}</Paragraph>
|
||||
<Seperator />
|
||||
<Input
|
||||
<FormInput
|
||||
name="code"
|
||||
formRef={formRef}
|
||||
fwdRef={codeInputRef}
|
||||
placeholder="xxxxxx"
|
||||
maxLength={6}
|
||||
loading={loading}
|
||||
textAlign="center"
|
||||
keyboardType="numeric"
|
||||
onChangeText={(value) => (code.current = value)}
|
||||
onChangeText={() => {
|
||||
setGeneralError(undefined);
|
||||
}}
|
||||
onSubmitEditing={onNext}
|
||||
returnKeyLabel={strings.next()}
|
||||
returnKeyType="done"
|
||||
validators={codeValidators}
|
||||
inputStyle={{
|
||||
fontSize: AppFontSize.lg,
|
||||
height: 60,
|
||||
@@ -392,7 +482,25 @@ export const MFASetup = ({
|
||||
borderWidth: 0,
|
||||
width: undefined
|
||||
}}
|
||||
errorStyle={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
/>
|
||||
|
||||
{generalError ? (
|
||||
<Paragraph
|
||||
size={AppFontSize.sm}
|
||||
style={{
|
||||
color: colors.error.icon,
|
||||
marginBottom: DefaultAppStyles.GAP_VERTICAL,
|
||||
textAlign: "center",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
{generalError}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
|
||||
<Seperator />
|
||||
<Button
|
||||
title={enabling ? null : strings.next()}
|
||||
@@ -517,7 +625,7 @@ export const MFARecoveryCodes = ({
|
||||
ToastManager.show({
|
||||
heading: strings.codesCopied(),
|
||||
type: "success",
|
||||
context: "global"
|
||||
context: "local"
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
|
||||
@@ -16,15 +16,20 @@ 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 { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { db } from "../../../common/database";
|
||||
import { eSendEvent, ToastManager } from "../../../services/event-manager";
|
||||
import { eUserLoggedIn } from "../../../utils/events";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import Input from "../../../components/ui/input";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../../components/ui/input/form-input";
|
||||
import { eSendEvent, ToastManager } from "../../../services/event-manager";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { eUserLoggedIn } from "../../../utils/events";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
|
||||
enum EmailChangeSteps {
|
||||
verify,
|
||||
@@ -32,14 +37,16 @@ enum EmailChangeSteps {
|
||||
}
|
||||
|
||||
export const ChangeEmail = () => {
|
||||
const { colors } = useThemeColors();
|
||||
const [step, setStep] = useState(EmailChangeSteps.verify);
|
||||
const emailChangeData = useRef<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
code?: string;
|
||||
}>({});
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
email: "",
|
||||
password: "",
|
||||
code: ""
|
||||
})
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const passInputRef = useRef<TextInput>(null);
|
||||
const codeInputRef = useRef<TextInput>(null);
|
||||
@@ -47,44 +54,52 @@ export const ChangeEmail = () => {
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
if (step === EmailChangeSteps.verify) {
|
||||
if (
|
||||
!emailChangeData.current.email ||
|
||||
!emailChangeData.current.password ||
|
||||
error
|
||||
)
|
||||
return;
|
||||
const hasEmailError = formRef.current.validateField("email");
|
||||
const hasPasswordError = formRef.current.validateField("password");
|
||||
if (hasEmailError || hasPasswordError) return;
|
||||
|
||||
const { email, password } = formRef.current.getValues();
|
||||
|
||||
setLoading(true);
|
||||
const verified = await db.user?.verifyPassword(
|
||||
emailChangeData.current.password
|
||||
);
|
||||
const verified = await db.user?.verifyPassword(password);
|
||||
if (!verified) throw new Error(strings.passwordIncorrect());
|
||||
await db.user?.sendVerificationEmail(emailChangeData.current.email);
|
||||
await db.user?.sendVerificationEmail(email);
|
||||
setStep(EmailChangeSteps.changeEmail);
|
||||
formRef.current.clearErrors();
|
||||
formRef.current.setValue("code", "");
|
||||
setLoading(false);
|
||||
} else {
|
||||
if (
|
||||
!emailChangeData.current.email ||
|
||||
!emailChangeData.current.password ||
|
||||
error ||
|
||||
!emailChangeData.current.code
|
||||
)
|
||||
return;
|
||||
await db.user?.changeEmail(
|
||||
emailChangeData.current.email,
|
||||
emailChangeData.current.password,
|
||||
emailChangeData.current.code
|
||||
);
|
||||
const hasCodeError = formRef.current.validateField("code");
|
||||
if (hasCodeError) return;
|
||||
|
||||
const { email, password, code } = formRef.current.getValues();
|
||||
|
||||
setLoading(true);
|
||||
await db.user?.changeEmail(email, password, code);
|
||||
eSendEvent(eUserLoggedIn);
|
||||
close?.();
|
||||
ToastManager.show({
|
||||
heading: strings.emailUpdated(emailChangeData.current.email),
|
||||
heading: strings.emailUpdated(email),
|
||||
type: "success",
|
||||
context: "global"
|
||||
});
|
||||
Navigation.goBack();
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
ToastManager.error(e as Error);
|
||||
const error = e as Error;
|
||||
|
||||
if (step === EmailChangeSteps.verify) {
|
||||
if (error.message === strings.passwordIncorrect()) {
|
||||
formRef.current.setError("password", error.message);
|
||||
} else {
|
||||
formRef.current.setError("email", error.message);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
formRef.current.setError("code", error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,34 +112,54 @@ export const ChangeEmail = () => {
|
||||
>
|
||||
{step === EmailChangeSteps.verify ? (
|
||||
<>
|
||||
<Input
|
||||
<FormInput
|
||||
name="email"
|
||||
formRef={formRef}
|
||||
fwdRef={emailInputRef}
|
||||
placeholder={strings.enterNewEmail()}
|
||||
validationType="email"
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
onChangeText={(email) => {
|
||||
emailChangeData.current.email = email;
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
validators={[
|
||||
validators.required(strings.emailRequired()),
|
||||
validators.email(strings.enterValidEmail())
|
||||
]}
|
||||
onSubmitEditing={() => {
|
||||
passInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={passInputRef}
|
||||
placeholder={strings.enterAccountPassword()}
|
||||
secureTextEntry
|
||||
onChangeText={(pass) => {
|
||||
emailChangeData.current.password = pass;
|
||||
}}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password"
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
key="code-input"
|
||||
<FormInput
|
||||
name="code"
|
||||
formRef={formRef}
|
||||
fwdRef={codeInputRef}
|
||||
placeholder={strings.code()}
|
||||
defaultValue=""
|
||||
onChangeText={(code) => {
|
||||
emailChangeData.current.code = code;
|
||||
}}
|
||||
keyboardType="number-pad"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={6}
|
||||
validators={[
|
||||
validators.required(strings.enterSixDigitCode()),
|
||||
(value: string) =>
|
||||
/^\d{6}$/.test(value.trim())
|
||||
? undefined
|
||||
: strings.enterSixDigitCode()
|
||||
]}
|
||||
onSubmitEditing={onSubmit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -135,8 +170,8 @@ export const ChangeEmail = () => {
|
||||
loading
|
||||
? undefined
|
||||
: step === EmailChangeSteps.verify
|
||||
? strings.verify()
|
||||
: strings.changeEmail()
|
||||
? strings.verify()
|
||||
: strings.changeEmail()
|
||||
}
|
||||
type="accent"
|
||||
style={{
|
||||
|
||||
@@ -47,6 +47,14 @@ export async function verifyUser(
|
||||
negativeText: closeText || strings.cancel(),
|
||||
positivePress: async (value) => {
|
||||
try {
|
||||
if (!value || !value.trim()) {
|
||||
ToastManager.error(
|
||||
new Error(strings.passwordNotEntered()),
|
||||
undefined,
|
||||
"local"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const user = await db.user.getUser();
|
||||
let verified = !user ? true : await db.user.verifyPassword(value);
|
||||
if (verified) {
|
||||
@@ -95,6 +103,14 @@ export async function verifyUserWithApplock() {
|
||||
keyboardType: keyboardType,
|
||||
positivePress: async (value) => {
|
||||
try {
|
||||
if (!value || !value.trim()) {
|
||||
ToastManager.error(
|
||||
new Error(strings.passwordNotEntered()),
|
||||
undefined,
|
||||
"local"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const verified = await validateAppLockPassword(value);
|
||||
if (!verified) {
|
||||
ToastManager.show({
|
||||
|
||||
@@ -31,11 +31,10 @@ import dayjs from "dayjs";
|
||||
import React from "react";
|
||||
import { Appearance, Linking, Platform } from "react-native";
|
||||
import { getVersion } from "react-native-device-info";
|
||||
import { TextInput } from "react-native-gesture-handler";
|
||||
import * as RNIap from "react-native-iap";
|
||||
//@ts-ignore
|
||||
import { enabled } from "react-native-privacy-snapshot";
|
||||
import ScreenGuardModule from "react-native-screenguard";
|
||||
import { DatabaseLogger, db } from "../../common/database";
|
||||
import { MMKV } from "../../common/database/mmkv";
|
||||
import filesystem from "../../common/filesystem";
|
||||
import { presentDialog } from "../../components/dialog/functions";
|
||||
import { AppLockPassword } from "../../components/dialogs/applock-password";
|
||||
@@ -44,43 +43,45 @@ import ExportNotesSheet from "../../components/sheets/export-notes";
|
||||
import { Issue } from "../../components/sheets/github/issue";
|
||||
import { Progress } from "../../components/sheets/progress";
|
||||
import { Update } from "../../components/sheets/update";
|
||||
import {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../components/ui/input/form-input";
|
||||
import { VaultStatusType, useVaultStatus } from "../../hooks/use-vault-status";
|
||||
import { BackgroundSync } from "../../services/background-sync";
|
||||
import BackupService from "../../services/backup";
|
||||
import BiometricService from "../../services/biometrics";
|
||||
import {
|
||||
ToastManager,
|
||||
VaultRequestType,
|
||||
eSendEvent,
|
||||
eSubscribeEvent,
|
||||
openVault,
|
||||
presentSheet,
|
||||
VaultRequestType
|
||||
presentSheet
|
||||
} from "../../services/event-manager";
|
||||
import Navigation from "../../services/navigation";
|
||||
import Notifications from "../../services/notifications";
|
||||
import PremiumService from "../../services/premium";
|
||||
import SettingsService from "../../services/settings";
|
||||
import Sync from "../../services/sync";
|
||||
import { clearAllStores } from "../../stores";
|
||||
import { refreshAllStores } from "../../stores/create-db-collection-store";
|
||||
import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { useUserStore } from "../../stores/use-user-store";
|
||||
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
|
||||
import {
|
||||
eAfterSync,
|
||||
eCloseSheet,
|
||||
eOpenRecoveryKeyDialog
|
||||
} from "../../utils/events";
|
||||
import { NotesnookModule } from "../../utils/notesnook-module";
|
||||
import { sleep } from "../../utils/time";
|
||||
import { resetTabStore } from "../editor/tiptap/use-tab-store";
|
||||
import { MFARecoveryCodes, MFASheet } from "./2fa";
|
||||
import { useDragState } from "./editor/state";
|
||||
import { verifyUser, verifyUserWithApplock } from "./functions";
|
||||
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[] = [
|
||||
{
|
||||
@@ -206,15 +207,15 @@ export const settingsGroups: SettingSection[] = [
|
||||
return status === SubscriptionStatus.TRIAL
|
||||
? strings.trialOnGoing(trialEndDate)
|
||||
: status === SubscriptionStatus.ACTIVE
|
||||
? strings.subRenewOn(expiryDate)
|
||||
: status === SubscriptionStatus.CANCELED ||
|
||||
status === SubscriptionStatus.PAUSED
|
||||
? strings.subEndsOn(expiryDate)
|
||||
: status === SubscriptionStatus.EXPIRED
|
||||
? subscriptionDaysLeft.time < -3
|
||||
? strings.subEnded()
|
||||
: strings.accountDowngradedIn(3)
|
||||
: strings.neverHesitate();
|
||||
? strings.subRenewOn(expiryDate)
|
||||
: status === SubscriptionStatus.CANCELED ||
|
||||
status === SubscriptionStatus.PAUSED
|
||||
? strings.subEndsOn(expiryDate)
|
||||
: status === SubscriptionStatus.EXPIRED
|
||||
? subscriptionDaysLeft.time < -3
|
||||
? strings.subEnded()
|
||||
: strings.accountDowngradedIn(3)
|
||||
: strings.neverHesitate();
|
||||
}
|
||||
|
||||
return strings.neverHesitate();
|
||||
@@ -236,18 +237,29 @@ export const settingsGroups: SettingSection[] = [
|
||||
presentDialog({
|
||||
title: strings.redeemGiftCode(),
|
||||
paragraph: strings.redeemGiftCodeDesc(),
|
||||
input: true,
|
||||
inputPlaceholder: strings.code(),
|
||||
positiveText: strings.redeem(),
|
||||
positivePress: async (value) => {
|
||||
db.subscriptions.redeemCode(value).catch((e) => {
|
||||
ToastManager.show({
|
||||
heading: "Error redeeming code",
|
||||
message: (e as Error).message,
|
||||
type: "error"
|
||||
});
|
||||
});
|
||||
}
|
||||
form: {
|
||||
formRef: createFormRef({
|
||||
code: ""
|
||||
}),
|
||||
items: [
|
||||
{
|
||||
name: "code",
|
||||
placeholder: strings.code(),
|
||||
ref: React.createRef<TextInput | null>(),
|
||||
validators: [validators.required(strings.giftCodeRequired())]
|
||||
}
|
||||
],
|
||||
onFormSubmit: async (form) => {
|
||||
try {
|
||||
await db.subscriptions.redeemCode(form.getValue("code"));
|
||||
return true;
|
||||
} catch (e) {
|
||||
form.setError("code", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
positiveText: strings.redeem()
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -417,36 +429,61 @@ export const settingsGroups: SettingSection[] = [
|
||||
{
|
||||
id: "subscription-not-active",
|
||||
name: strings.subscriptionNotActivated(),
|
||||
hidden: () => Platform.OS !== "ios",
|
||||
useHook: () => useUserStore((state) => state.user),
|
||||
hidden: (user) =>
|
||||
Platform.OS !== "ios" ||
|
||||
(user as User)?.subscription?.plan !== SubscriptionPlan.FREE,
|
||||
modifer: async () => {
|
||||
if (Platform.OS === "android") return;
|
||||
presentSheet({
|
||||
title: strings.loadingSubscription(),
|
||||
paragraph: strings.loadingSubscriptionDesc()
|
||||
});
|
||||
const subscriptions = await RNIap.getPurchaseHistory();
|
||||
subscriptions.sort(
|
||||
(a, b) => b.transactionDate - a.transactionDate
|
||||
);
|
||||
const currentSubscription = subscriptions[0];
|
||||
presentSheet({
|
||||
title: strings.notesnookPro(),
|
||||
paragraph: strings.subscribedOnVerify(
|
||||
new Date(currentSubscription.transactionDate).toLocaleString()
|
||||
),
|
||||
action: async () => {
|
||||
presentSheet({
|
||||
title: strings.verifySubscription(),
|
||||
paragraph: strings.subscriptionVerifyWait()
|
||||
try {
|
||||
presentSheet({
|
||||
title: strings.loadingSubscription(),
|
||||
paragraph: strings.loadingSubscriptionDesc(),
|
||||
progress: true
|
||||
});
|
||||
const subscriptions = await RNIap.getPurchaseHistory();
|
||||
subscriptions.sort(
|
||||
(a, b) => b.transactionDate - a.transactionDate
|
||||
);
|
||||
const currentSubscription = subscriptions[0];
|
||||
|
||||
if (
|
||||
!currentSubscription ||
|
||||
dayjs(currentSubscription.transactionDate).isBefore(
|
||||
dayjs().subtract(30, "day")
|
||||
)
|
||||
) {
|
||||
ToastManager.show({
|
||||
message: "No active subscription found",
|
||||
type: "info"
|
||||
});
|
||||
await PremiumService.subscriptions.verify(
|
||||
currentSubscription
|
||||
);
|
||||
eSendEvent(eCloseSheet);
|
||||
},
|
||||
icon: "information-outline",
|
||||
actionText: strings.verify()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
presentSheet({
|
||||
title: strings.notesnookPro(),
|
||||
paragraph: strings.subscribedOnVerify(
|
||||
new Date(
|
||||
currentSubscription.transactionDate
|
||||
).toLocaleString()
|
||||
),
|
||||
action: async () => {
|
||||
presentSheet({
|
||||
title: strings.verifySubscription(),
|
||||
paragraph: strings.subscriptionVerifyWait()
|
||||
});
|
||||
await PremiumService.subscriptions.verify(
|
||||
currentSubscription
|
||||
);
|
||||
eSendEvent(eCloseSheet);
|
||||
},
|
||||
icon: "information-outline",
|
||||
actionText: strings.verify()
|
||||
});
|
||||
} catch (e) {
|
||||
eSendEvent(eCloseSheet);
|
||||
}
|
||||
},
|
||||
description: strings.verifySubDesc()
|
||||
},
|
||||
@@ -517,6 +554,14 @@ export const settingsGroups: SettingSection[] = [
|
||||
positiveText: strings.delete(),
|
||||
positivePress: async (value) => {
|
||||
try {
|
||||
if (!value || !value.trim()) {
|
||||
ToastManager.error(
|
||||
new Error(strings.passwordNotEntered()),
|
||||
undefined,
|
||||
"local"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const verified = await db.user?.verifyPassword(value);
|
||||
if (verified) {
|
||||
setTimeout(async () => {
|
||||
@@ -1054,19 +1099,7 @@ export const settingsGroups: SettingSection[] = [
|
||||
description: strings.privacyModeDesc(),
|
||||
modifer: () => {
|
||||
const settings = SettingsService.get();
|
||||
if (Platform.OS === "ios") {
|
||||
enabled(!settings.privacyScreen);
|
||||
if (settings.privacyScreen) {
|
||||
ScreenGuardModule.unregister();
|
||||
} else {
|
||||
ScreenGuardModule.register({
|
||||
backgroundColor: "#000000"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
NotesnookModule.setSecureMode(!settings.privacyScreen);
|
||||
}
|
||||
|
||||
SettingsService.setPrivacyScreen(!settings.privacyScreen);
|
||||
SettingsService.set({ privacyScreen: !settings.privacyScreen });
|
||||
},
|
||||
property: "privacyScreen"
|
||||
@@ -1087,11 +1120,13 @@ export const settingsGroups: SettingSection[] = [
|
||||
type: "switch",
|
||||
property: "appLockEnabled",
|
||||
featureId: "appLock",
|
||||
onChange: () => {
|
||||
SettingsService.set({
|
||||
privacyScreen: true
|
||||
});
|
||||
SettingsService.setPrivacyScreen(SettingsService.get());
|
||||
onChange: (property) => {
|
||||
if (property) {
|
||||
SettingsService.set({
|
||||
privacyScreen: true
|
||||
});
|
||||
SettingsService.setPrivacyScreen(true);
|
||||
}
|
||||
},
|
||||
onVerify: async () => {
|
||||
const verified = await verifyUserWithApplock();
|
||||
|
||||
@@ -31,7 +31,11 @@ import type {
|
||||
ThemesRouter
|
||||
} from "@notesnook/themes-server";
|
||||
import { keepLocalCopy, pick } from "@react-native-documents/picker";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
notifyManager,
|
||||
QueryClient,
|
||||
QueryClientProvider
|
||||
} from "@tanstack/react-query";
|
||||
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import React, { useState } from "react";
|
||||
@@ -69,7 +73,8 @@ export const themeTrpcClient = createTRPCProxyClient<ThemesRouter>({
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
notifyManager.setBatchNotifyFunction((cb) => cb());
|
||||
notifyManager.setNotifyFunction((cb) => cb());
|
||||
function ThemeSelector() {
|
||||
const [darkTheme, lightTheme] = useThemeStore((state) => [
|
||||
state.darkTheme,
|
||||
@@ -80,30 +85,20 @@ function ThemeSelector() {
|
||||
const themeColors = colors;
|
||||
const [searchQuery, setSearchQuery] = useState<string>();
|
||||
const [colorScheme, setColorScheme] = useState<string>();
|
||||
|
||||
const filters = [];
|
||||
if (searchQuery) filters.push({ type: "term" as const, value: searchQuery });
|
||||
if (colorScheme)
|
||||
filters.push({ type: "colorScheme" as const, value: colorScheme });
|
||||
|
||||
const themes = trpc.themes.useInfiniteQuery(
|
||||
{
|
||||
limit: 10,
|
||||
compatibilityVersion: THEME_COMPATIBILITY_VERSION,
|
||||
filters: [
|
||||
...(searchQuery && searchQuery !== ""
|
||||
? [
|
||||
{
|
||||
type: "term" as const,
|
||||
value: searchQuery
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(colorScheme && colorScheme !== ""
|
||||
? [
|
||||
{
|
||||
type: "colorScheme" as const,
|
||||
value: colorScheme
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
filters
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor
|
||||
}
|
||||
);
|
||||
@@ -366,12 +361,14 @@ function ThemeSelector() {
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row"
|
||||
flexDirection: "row",
|
||||
gap: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
paddingHorizontal: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
type={
|
||||
colorScheme === "" || !colorScheme ? "accent" : "secondary"
|
||||
@@ -384,7 +381,8 @@ function ThemeSelector() {
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
paddingHorizontal: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
type={colorScheme === "dark" ? "accent" : "secondary"}
|
||||
title={strings.dark()}
|
||||
@@ -395,7 +393,8 @@ function ThemeSelector() {
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
paddingHorizontal: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
fontSize={AppFontSize.xs}
|
||||
type={colorScheme === "light" ? "accent" : "secondary"}
|
||||
@@ -409,7 +408,8 @@ function ThemeSelector() {
|
||||
<Button
|
||||
title={strings.loadFromFile()}
|
||||
style={{
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
paddingHorizontal: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
type={"secondaryAccented"}
|
||||
icon="folder"
|
||||
|
||||
@@ -29,6 +29,10 @@ import ImagePicker from "react-native-image-crop-picker";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../common/database";
|
||||
import { presentDialog } from "../../components/dialog/functions";
|
||||
import {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../components/ui/input/form-input";
|
||||
import { PlanLimits } from "../../components/sheets/plan-limits";
|
||||
import AppIcon from "../../components/ui/AppIcon";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -197,19 +201,34 @@ const SettingsUserSection = ({ item }) => {
|
||||
title: strings.setFullName(),
|
||||
paragraph: strings.setFullNameDesc(),
|
||||
positiveText: strings.save(),
|
||||
input: true,
|
||||
inputPlaceholder: strings.enterFullName(),
|
||||
defaultValue: userProfile?.fullName,
|
||||
positivePress: async (value) => {
|
||||
db.settings
|
||||
.setProfile({
|
||||
fullName: value
|
||||
})
|
||||
.then(async () => {
|
||||
form: {
|
||||
formRef: createFormRef({
|
||||
fullName: userProfile?.fullName || ""
|
||||
}),
|
||||
items: [
|
||||
{
|
||||
name: "fullName",
|
||||
placeholder: strings.enterFullName(),
|
||||
defaultValue: userProfile?.fullName,
|
||||
validators: [
|
||||
validators.required(strings.nameIsRequired())
|
||||
]
|
||||
}
|
||||
],
|
||||
onFormSubmit: async (form) => {
|
||||
try {
|
||||
await db.settings.setProfile({
|
||||
fullName: form.getValue("fullName")
|
||||
});
|
||||
useUserStore.setState({
|
||||
profile: db.settings.getProfile()
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
form.setError("fullName", e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -98,6 +98,7 @@ export const Trash = ({ navigation, route }: NavigationProps<"Trash">) => {
|
||||
<List
|
||||
data={trash}
|
||||
dataType="trash"
|
||||
groupType="trash"
|
||||
renderedInRoute="Trash"
|
||||
loading={!isFocused}
|
||||
placeholder={PLACEHOLDER_DATA(db.settings.getTrashCleanupInterval())}
|
||||
|
||||
@@ -71,6 +71,7 @@ const routeNames = {
|
||||
Archive: "Archive",
|
||||
ManageTags: "ManageTags",
|
||||
AddReminder: "AddReminder",
|
||||
RelationsList: "RelationsList",
|
||||
PayWall: "PayWall",
|
||||
Wrapped: "Wrapped"
|
||||
};
|
||||
|
||||
@@ -17,9 +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 { NativeModules, Platform } from "react-native";
|
||||
//@ts-ignore
|
||||
import { enabled } from "react-native-privacy-snapshot";
|
||||
import { Platform } from "react-native";
|
||||
import { MMKV } from "../common/database/mmkv";
|
||||
import {
|
||||
SettingStore,
|
||||
@@ -28,9 +26,9 @@ import {
|
||||
} from "../stores/use-setting-store";
|
||||
import { NotesnookModule } from "../utils/notesnook-module";
|
||||
import { scale, updateSize } from "../utils/size";
|
||||
import { DatabaseLogger } from "../common/database";
|
||||
import { useUserStore } from "../stores/use-user-store";
|
||||
import ScreenGuardModule from "react-native-screenguard";
|
||||
ScreenGuardModule.initSettings();
|
||||
|
||||
function reset() {
|
||||
const settings = get();
|
||||
@@ -54,11 +52,10 @@ function resetSettings() {
|
||||
defaultSnoozeTime: get().defaultSnoozeTime,
|
||||
defaultFontFamily: get().defaultFontFamily,
|
||||
defaultFontSize: get().defaultFontSize,
|
||||
privacyScreen: get().privacyScreen,
|
||||
corsProxy: get().corsProxy,
|
||||
showBackupCompleteSheet: get().showBackupCompleteSheet
|
||||
};
|
||||
|
||||
setPrivacyScreen(false);
|
||||
MMKV.setString("appSettings", JSON.stringify(settings));
|
||||
set(settings);
|
||||
init();
|
||||
@@ -91,7 +88,6 @@ function migrateAppLock() {
|
||||
biometricsAuthEnabled: true
|
||||
});
|
||||
}
|
||||
DatabaseLogger.debug("App lock Migrated");
|
||||
}
|
||||
|
||||
function migrateSettings(settings: SettingStore["settings"]) {
|
||||
@@ -101,6 +97,7 @@ function migrateSettings(settings: SettingStore["settings"]) {
|
||||
settings.privacyScreen = settings.appLockEnabled
|
||||
? true
|
||||
: settings.privacyScreen;
|
||||
setPrivacyScreen(settings.privacyScreen);
|
||||
MMKV.setString("appSettings", JSON.stringify(settings));
|
||||
}
|
||||
}
|
||||
@@ -126,28 +123,21 @@ function init() {
|
||||
updateSize();
|
||||
useSettingStore.getState().setSettings({ ...settings });
|
||||
migrateAppLock();
|
||||
setPrivacyScreen(settings);
|
||||
}
|
||||
|
||||
function setPrivacyScreen(settings: SettingStore["settings"]) {
|
||||
if (settings.privacyScreen) {
|
||||
function setPrivacyScreen(enabled?: boolean) {
|
||||
if (enabled) {
|
||||
if (Platform.OS === "android") {
|
||||
NotesnookModule.setSecureMode(true);
|
||||
ScreenGuardModule.registerWithoutEffect();
|
||||
} else {
|
||||
enabled(true);
|
||||
if (NativeModules.ScreenGuard) {
|
||||
ScreenGuardModule.register({ backgroundColor: "#000000" });
|
||||
}
|
||||
ScreenGuardModule.register({ backgroundColor: "#000000" });
|
||||
}
|
||||
} else {
|
||||
if (Platform.OS === "android") {
|
||||
NotesnookModule.setSecureMode(false);
|
||||
} else {
|
||||
enabled(false);
|
||||
if (NativeModules.ScreenGuard) {
|
||||
ScreenGuardModule.unregister();
|
||||
}
|
||||
}
|
||||
ScreenGuardModule.unregister();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,13 @@ export interface RouteParams extends ParamListBase {
|
||||
reminder?: Reminder;
|
||||
reference?: Note;
|
||||
};
|
||||
RelationsList: {
|
||||
item: Item;
|
||||
referenceType: "notebook" | "tag" | "reminder" | "note";
|
||||
relationType: "to" | "from";
|
||||
title: string;
|
||||
onAdd?: () => void;
|
||||
};
|
||||
Intro: GenericRouteParam;
|
||||
PayWall: {
|
||||
canGoBack?: boolean;
|
||||
|
||||
@@ -58,6 +58,7 @@ export type Settings = {
|
||||
appLockMode?: "none" | "background" | "launch";
|
||||
notebooksListMode?: "normal" | "compact";
|
||||
notesListMode?: "normal" | "compact";
|
||||
searchListMode?: "normal" | "compact";
|
||||
devMode?: boolean;
|
||||
notifNotes?: boolean;
|
||||
pitchBlack?: boolean;
|
||||
@@ -172,6 +173,7 @@ export const defaultSettings: SettingStore["settings"] = {
|
||||
appLockMode: "none",
|
||||
notebooksListMode: "normal",
|
||||
notesListMode: "normal",
|
||||
searchListMode: "normal",
|
||||
devMode: false,
|
||||
notifNotes: false,
|
||||
pitchBlack: false,
|
||||
|
||||
@@ -48,7 +48,8 @@ export const SORT = {
|
||||
dateCreated: "Date created",
|
||||
title: "Title",
|
||||
dueDate: "Due date",
|
||||
relevance: "Relevance"
|
||||
relevance: "Relevance",
|
||||
dateDeleted: "Date deleted"
|
||||
};
|
||||
|
||||
export const itemSkus = [
|
||||
|
||||
@@ -65,6 +65,14 @@ export async function unlockVault({
|
||||
paragraph: paragraph,
|
||||
inputPlaceholder: strings.enterPassword(),
|
||||
positivePress: async (value) => {
|
||||
if (!value || !value.trim()) {
|
||||
ToastManager.error(
|
||||
new Error(strings.passwordNotEntered()),
|
||||
undefined,
|
||||
"local"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const unlocked = await db.vault.unlock(value);
|
||||
if (!unlocked) {
|
||||
ToastManager.show({
|
||||
|
||||
Binary file not shown.
@@ -2235,14 +2235,35 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- react-native-screenguard (1.0.0):
|
||||
- react-native-screenguard (2.0.0-beta5):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
- fmt
|
||||
- glog
|
||||
- hermes-engine
|
||||
- RCT-Folly
|
||||
- RCT-Folly/Fabric
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Codegen
|
||||
- React-Core
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- SDWebImage (~> 5.11.1)
|
||||
- SDWebImage (~> 5.11)
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- react-native-share-extension (2.9.5):
|
||||
- React
|
||||
- react-native-sodium (1.6.8):
|
||||
@@ -3097,8 +3118,6 @@ PODS:
|
||||
- RNNotifee/NotifeeCore (= 7.4.12)
|
||||
- RNNotifee/NotifeeCore (7.4.12):
|
||||
- React-Core
|
||||
- RNPrivacySnapshot (1.0.0):
|
||||
- React-Core
|
||||
- RNReanimated (4.2.0):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
@@ -3573,7 +3592,6 @@ DEPENDENCIES:
|
||||
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
|
||||
- RNKeychain (from `../node_modules/react-native-keychain`)
|
||||
- "RNNotifee (from `../node_modules/@ammarahmed/notifee-react-native`)"
|
||||
- RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`)
|
||||
- RNReanimated (from `../node_modules/react-native-reanimated`)
|
||||
- RNScreens (from `../node_modules/react-native-screens`)
|
||||
- RNSecureRandom (from `../node_modules/react-native-securerandom`)
|
||||
@@ -3839,8 +3857,6 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/react-native-keychain"
|
||||
RNNotifee:
|
||||
:path: "../node_modules/@ammarahmed/notifee-react-native"
|
||||
RNPrivacySnapshot:
|
||||
:path: "../node_modules/react-native-privacy-snapshot"
|
||||
RNReanimated:
|
||||
:path: "../node_modules/react-native-reanimated"
|
||||
RNScreens:
|
||||
@@ -3942,7 +3958,7 @@ SPEC CHECKSUMS:
|
||||
react-native-pdf: edc236298f13f1609e42d41e45b8b6ea88ed10f9
|
||||
react-native-quick-sqlite: 1ed8d3db1e22a8604d006be69f06053382e93bb0
|
||||
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
|
||||
react-native-screenguard: 9fc3b4ad5b97783fc0832638fae0dce51272c661
|
||||
react-native-screenguard: 975d4612dce0c348b19b08a48aa23e9b68584d98
|
||||
react-native-share-extension: fdc6aaab51591a2d445df239c446aaa3a99658ec
|
||||
react-native-sodium: 066f76e46c9be13e9260521e3fa994937c4cdab4
|
||||
react-native-theme-switch-animation: 449d6db7a760f55740505e7403ae8061debc9a7e
|
||||
@@ -3995,7 +4011,6 @@ SPEC CHECKSUMS:
|
||||
RNImageCropPicker: 5fd4ceaead64d8c53c787e4e559004f97bc76df7
|
||||
RNKeychain: ffd0513e676445c637410b47249460cbf56bc9cb
|
||||
RNNotifee: dea82c9ec44684eeeac9da85deb5eeb8ebe5937f
|
||||
RNPrivacySnapshot: ccad3a548338c2f526bb7b1789af3fb0618b7d1d
|
||||
RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee
|
||||
RNScreens: ffbb0296608eb3560de641a711bbdb663ed1f6b4
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Production iOS build identifiers
|
||||
IOS_CURRENT_PROJECT_VERSION = 2180
|
||||
IOS_MARKETING_VERSION = 3.3.20
|
||||
IOS_CURRENT_PROJECT_VERSION = 2185
|
||||
IOS_MARKETING_VERSION = 3.3.25
|
||||
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
|
||||
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
|
||||
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Production iOS build identifiers
|
||||
IOS_CURRENT_PROJECT_VERSION = 2180
|
||||
IOS_MARKETING_VERSION = 3.3.20
|
||||
IOS_CURRENT_PROJECT_VERSION = 2185
|
||||
IOS_MARKETING_VERSION = 3.3.25
|
||||
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
|
||||
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
|
||||
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Staging iOS build identifiers
|
||||
IOS_CURRENT_PROJECT_VERSION = 2180
|
||||
IOS_MARKETING_VERSION = 3.3.20
|
||||
IOS_CURRENT_PROJECT_VERSION = 2185
|
||||
IOS_MARKETING_VERSION = 3.3.25
|
||||
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
|
||||
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
|
||||
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share
|
||||
|
||||
59
apps/mobile/package-lock.json
generated
59
apps/mobile/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.19",
|
||||
"version": "3.3.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.19",
|
||||
"version": "3.3.25",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
@@ -51,10 +51,10 @@
|
||||
"@sayem314/react-native-keep-awake": "^1.3.1",
|
||||
"@streetwriters/kysely": "^0.27.4",
|
||||
"@streetwriters/showdown": "^3.0.1-alpha.2",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/react-query": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@tanstack/react-query": "^4.18.0",
|
||||
"@trpc/client": "10.45.2",
|
||||
"@trpc/react-query": "10.45.2",
|
||||
"@trpc/server": "10.45.2",
|
||||
"@types/validator": "^13.12.2",
|
||||
"absolutify": "^0.1.0",
|
||||
"async-mutex": "0.5.0",
|
||||
@@ -105,7 +105,6 @@
|
||||
"react-native-notification-sounds": "0.5.5",
|
||||
"react-native-orientation-locker": "^1.7.0",
|
||||
"react-native-pdf": "^7.0.3",
|
||||
"react-native-privacy-snapshot": "github:standardnotes/react-native-privacy-snapshot",
|
||||
"react-native-progress": "5.0.0",
|
||||
"react-native-qrcode-svg": "^6.0.6",
|
||||
"react-native-quick-sqlite": "^8.2.7",
|
||||
@@ -113,7 +112,7 @@
|
||||
"react-native-reanimated-progress-bar": "1.0.1",
|
||||
"react-native-safe-area-context": "^5.6.1",
|
||||
"react-native-scoped-storage": "^1.9.5",
|
||||
"react-native-screenguard": "1.0.0",
|
||||
"react-native-screenguard": "^2.0.0-beta5",
|
||||
"react-native-screens": "^4.19.0",
|
||||
"react-native-securerandom": "^1.0.1",
|
||||
"react-native-share": "^12.0.3",
|
||||
@@ -5635,9 +5634,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.41.0.tgz",
|
||||
"integrity": "sha512-193R4Jp9hjvlij6LryxrB5Mpbffd2L9PeWh3KlIy/hJV4SkBOfiQZ+jc5qAZLDCrdbkA5FjGj+UoDYw6TcNnyA==",
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.18.0.tgz",
|
||||
"integrity": "sha512-PP4mG8MD08sq64RZCqMfXMYfaj7+Oulwg7xZ/fJoEOdTZNcPIgaOkHajZvUBsNLbi/0ViMvJB4cFkL2Jg2WPbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -5645,12 +5644,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "4.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.42.0.tgz",
|
||||
"integrity": "sha512-j0tiofkzE3CSrYKmVRaKuwGgvCE+P2OOEDlhmfjeZf5ufcuFHwYwwgw3j08n4WYPVZ+OpsHblcFYezhKA3jDwg==",
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.18.0.tgz",
|
||||
"integrity": "sha512-s1kdbGMdVcfUIllzsHUqVUdktBT5uuIRgnvrqFNLjl9TSOXEoBSDrhjsGjao0INQZv8cMpQlgOh3YH9YtN6cKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "4.41.0",
|
||||
"@tanstack/query-core": "4.18.0",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
@@ -5658,8 +5657,8 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -15884,15 +15883,6 @@
|
||||
"prop-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-privacy-snapshot": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "git+ssh://git@github.com/standardnotes/react-native-privacy-snapshot.git#653e904c90fc6f2b578da59138f2bfe5d7f942fe",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1",
|
||||
"react-native": "^0.62.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-progress": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-progress/-/react-native-progress-5.0.0.tgz",
|
||||
@@ -15993,16 +15983,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-screenguard": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-screenguard/-/react-native-screenguard-1.0.0.tgz",
|
||||
"integrity": "sha512-fqtoJI8TxszKqrntnHW48EMb4gNVA9VCAg5UG1FcIXEyv2W70kWoMxRRFoWuCRYlBWSAWqiXO+R6Xfk/0um0cw==",
|
||||
"version": "2.0.0-beta5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-screenguard/-/react-native-screenguard-2.0.0-beta5.tgz",
|
||||
"integrity": "sha512-v8Lnv2am+COzEGKvj6J4UbpQsjHixkSqSJ+tgJtDvwck2Dil1vwOhEJBbngXYgj63x4iFlV9gE4gAOWG60avAg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
"expo": ">=36.0.0",
|
||||
"react": ">=16.8.3",
|
||||
"react-native": ">=0.59.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-screens": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.20",
|
||||
"version": "3.3.25",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
@@ -69,10 +69,10 @@
|
||||
"@sayem314/react-native-keep-awake": "^1.3.1",
|
||||
"@streetwriters/kysely": "^0.27.4",
|
||||
"@streetwriters/showdown": "^3.0.1-alpha.2",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/react-query": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@tanstack/react-query": "^4.18.0",
|
||||
"@trpc/client": "10.45.2",
|
||||
"@trpc/react-query": "10.45.2",
|
||||
"@trpc/server": "10.45.2",
|
||||
"@types/validator": "^13.12.2",
|
||||
"absolutify": "^0.1.0",
|
||||
"async-mutex": "0.5.0",
|
||||
@@ -123,7 +123,6 @@
|
||||
"react-native-notification-sounds": "0.5.5",
|
||||
"react-native-orientation-locker": "^1.7.0",
|
||||
"react-native-pdf": "^7.0.3",
|
||||
"react-native-privacy-snapshot": "github:standardnotes/react-native-privacy-snapshot",
|
||||
"react-native-progress": "5.0.0",
|
||||
"react-native-qrcode-svg": "^6.0.6",
|
||||
"react-native-quick-sqlite": "^8.2.7",
|
||||
@@ -131,7 +130,7 @@
|
||||
"react-native-reanimated-progress-bar": "1.0.1",
|
||||
"react-native-safe-area-context": "^5.6.1",
|
||||
"react-native-scoped-storage": "^1.9.5",
|
||||
"react-native-screenguard": "1.0.0",
|
||||
"react-native-screenguard": "^2.0.0-beta5",
|
||||
"react-native-screens": "^4.19.0",
|
||||
"react-native-securerandom": "^1.0.1",
|
||||
"react-native-share": "^12.0.3",
|
||||
@@ -202,4 +201,4 @@
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
diff --git a/node_modules/react-native-screenguard/ios/ScreenGuard.mm b/node_modules/react-native-screenguard/ios/ScreenGuard.mm
|
||||
index 9fa7d33..d2e6d68 100644
|
||||
--- a/node_modules/react-native-screenguard/ios/ScreenGuard.mm
|
||||
+++ b/node_modules/react-native-screenguard/ios/ScreenGuard.mm
|
||||
@@ -11,7 +11,7 @@
|
||||
@implementation ScreenGuard
|
||||
RCT_EXPORT_MODULE(ScreenGuard)
|
||||
|
||||
-bool hasListeners;
|
||||
+bool hasListeners_;
|
||||
|
||||
UITextField *textField;
|
||||
UIImageView *imageView;
|
||||
@@ -22,11 +22,11 @@ @implementation ScreenGuard
|
||||
}
|
||||
|
||||
- (void)startObserving {
|
||||
- hasListeners = YES;
|
||||
+ hasListeners_ = YES;
|
||||
}
|
||||
|
||||
- (void)stopObserving {
|
||||
- hasListeners = NO;
|
||||
+ hasListeners_ = NO;
|
||||
}
|
||||
|
||||
- (void)secureViewWithBackgroundColor: (NSString *)color {
|
||||
@@ -335,7 +335,7 @@ - (UIImage *)convertViewToImage:(UIView *)view {
|
||||
queue:mainQueue
|
||||
usingBlock:^(NSNotification *notification) {
|
||||
|
||||
- if (hasListeners && getScreenShotPath) {
|
||||
+ if (hasListeners_ && getScreenShotPath) {
|
||||
UIViewController *presentedViewController = RCTPresentedViewController();
|
||||
|
||||
UIImage *image = [self convertViewToImage:presentedViewController.view.superview];
|
||||
@@ -359,7 +359,7 @@ - (UIImage *)convertViewToImage:(UIView *)view {
|
||||
result = @{@"path": filePath, @"name": fileName, @"type": @"PNG"};
|
||||
}
|
||||
[self emit:SCREENSHOT_EVT body: result];
|
||||
- } else if (hasListeners) {
|
||||
+ } else if (hasListeners_) {
|
||||
[self emit:SCREENSHOT_EVT body: nil];
|
||||
}
|
||||
}];
|
||||
@@ -376,7 +376,7 @@ - (UIImage *)convertViewToImage:(UIView *)view {
|
||||
queue:mainQueue
|
||||
usingBlock:^(NSNotification *notification) {
|
||||
|
||||
- if (hasListeners) {
|
||||
+ if (hasListeners_) {
|
||||
[self emit:SCREEN_RECORDING_EVT body:nil];
|
||||
}
|
||||
}];
|
||||
@@ -11,12 +11,6 @@ config.dependencies["react-native-vector-icons"] = {
|
||||
}
|
||||
};
|
||||
|
||||
config.dependencies["react-native-screenguard"] = {
|
||||
platforms: {
|
||||
android: null
|
||||
}
|
||||
};
|
||||
|
||||
if (isGithubRelease) {
|
||||
config.dependencies["react-native-iap"] = {
|
||||
platforms: {
|
||||
|
||||
4
apps/web/package-lock.json
generated
4
apps/web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/web",
|
||||
"version": "3.3.16",
|
||||
"version": "3.3.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/web",
|
||||
"version": "3.3.16",
|
||||
"version": "3.3.23",
|
||||
"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.16",
|
||||
"version": "3.3.23",
|
||||
"private": true,
|
||||
"main": "./src/app.js",
|
||||
"homepage": "https://notesnook.com/",
|
||||
|
||||
@@ -215,7 +215,10 @@ function DesktopAppContents() {
|
||||
zIndex: 3
|
||||
}}
|
||||
>
|
||||
<NavigationMenu onExpand={() => navPane.current?.reset(0)} />
|
||||
<NavigationMenu
|
||||
onExpand={() => navPane.current?.reset(0)}
|
||||
canExpand={!isTablet}
|
||||
/>
|
||||
</Pane>
|
||||
)}
|
||||
{isFocusMode ? null : (
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -231,15 +231,40 @@ export async function restoreBackupFile(backupFile: File) {
|
||||
}
|
||||
await db.initCollections();
|
||||
} else {
|
||||
const { createUnzipIterator } = await import(
|
||||
"../utils/streams/unzip-stream"
|
||||
);
|
||||
|
||||
let skipAttachments = false;
|
||||
if (!useUserStore.getState().isLoggedIn) {
|
||||
let hasAttachments = false;
|
||||
for await (const entry of createUnzipIterator(backupFile)) {
|
||||
if (
|
||||
entry.name.startsWith("attachments/") &&
|
||||
entry.name !== "attachments/.attachments_key"
|
||||
) {
|
||||
hasAttachments = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAttachments) {
|
||||
const result = await ConfirmDialog.show({
|
||||
title: strings.loginToRestoreAttachments(),
|
||||
message: strings.loginToRestoreAttachmentsDesc(),
|
||||
positiveButtonText: strings.yes(),
|
||||
negativeButtonText: strings.no()
|
||||
});
|
||||
if (!result) return;
|
||||
skipAttachments = true;
|
||||
}
|
||||
}
|
||||
|
||||
const error = await TaskManager.startTask<Error | void>({
|
||||
title: strings.restoringBackup(),
|
||||
subtitle: strings.restoringBackupDesc(),
|
||||
type: "modal",
|
||||
action: async (report) => {
|
||||
const { createUnzipIterator } = await import(
|
||||
"../utils/streams/unzip-stream"
|
||||
);
|
||||
|
||||
let cachedPassword: string | undefined = undefined;
|
||||
let cachedKey: string | undefined = undefined;
|
||||
// const { read, totalFiles } = await Reader(backupFile);
|
||||
@@ -254,13 +279,13 @@ export async function restoreBackupFile(backupFile: File) {
|
||||
isValid = true;
|
||||
continue;
|
||||
}
|
||||
if (entry.name === "attachments/.attachments_key")
|
||||
if (!skipAttachments && entry.name === "attachments/.attachments_key")
|
||||
attachmentsKey = JSON.parse(await entry.text()) as
|
||||
| SerializedKey
|
||||
| Cipher<"base64">;
|
||||
else if (entry.name.startsWith("attachments/"))
|
||||
else if (!skipAttachments && entry.name.startsWith("attachments/"))
|
||||
attachments.push(entry);
|
||||
else entries.push(entry);
|
||||
else if (!entry.name.startsWith("attachments/")) entries.push(entry);
|
||||
}
|
||||
if (!isValid)
|
||||
console.warn(
|
||||
@@ -339,6 +364,8 @@ export async function restoreBackupFile(backupFile: File) {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
showToast("error", `${strings.restoreFailed()}: ${error.message}`);
|
||||
} else {
|
||||
showToast("success", strings.backupRestored());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { useMemo } from "react";
|
||||
import { Box, Button, Flex, Image, Link, Text } from "@theme-ui/components";
|
||||
import { getRandom, usePromise } from "@notesnook/common";
|
||||
import Grberk from "../../assets/testimonials/grberk.jpeg";
|
||||
import Holenstein from "../../assets/testimonials/holenstein.jpg";
|
||||
import Jason from "../../assets/testimonials/jason.jpg";
|
||||
import Cameron from "../../assets/testimonials/cameron.jpg";
|
||||
@@ -29,13 +28,6 @@ import { SettingsDialog } from "../../dialogs/settings";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
username: "grberk",
|
||||
image: Grberk,
|
||||
name: "Glenn Berkshier",
|
||||
link: "https://twitter.com/grberk/status/1438955961490751489",
|
||||
text: "Are you looking for an alternative to @evernote, or just looking for a more secure note taking platform? Take a look at @notesnook and see if it will fit your needs."
|
||||
},
|
||||
{
|
||||
username: "HolensteinDan",
|
||||
image: Holenstein,
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 { useDatePicker } from "@rehookify/datepicker";
|
||||
import { Box, Button, Flex, Text } from "@theme-ui/components";
|
||||
import { useState } from "react";
|
||||
@@ -31,6 +32,7 @@ type DayPickerProps = {
|
||||
export function DayPicker(props: DayPickerProps) {
|
||||
const { selected, sx, maxDate, minDate, onSelect } = props;
|
||||
const [selectedDates, onDatesChange] = useState<Date[]>([selected]);
|
||||
|
||||
const {
|
||||
data: { weekDays, months, years, calendars },
|
||||
propGetters: { dayButton, addOffset, subtractOffset, setOffset }
|
||||
@@ -44,7 +46,21 @@ export function DayPicker(props: DayPickerProps) {
|
||||
maxDate,
|
||||
minDate
|
||||
},
|
||||
years: { numberOfYears: 99 },
|
||||
years: {
|
||||
/**
|
||||
* When minDate and maxDate are provided, we switch to "fluid" mode and calculate the number of years in a way that all valid years are visible.
|
||||
*
|
||||
* When not provided, use default mode and numberOfYears.
|
||||
*/
|
||||
numberOfYears:
|
||||
minDate && maxDate
|
||||
? Math.max(
|
||||
Math.abs(maxDate.getFullYear() - selected.getFullYear()) + 1,
|
||||
Math.abs(selected.getFullYear() - minDate.getFullYear()) + 1
|
||||
) * 2
|
||||
: 99,
|
||||
mode: minDate && maxDate ? "fluid" : "decade"
|
||||
},
|
||||
calendar: {
|
||||
startDay: 0
|
||||
}
|
||||
@@ -52,6 +68,24 @@ export function DayPicker(props: DayPickerProps) {
|
||||
|
||||
const { month, year, days } = calendars[0];
|
||||
|
||||
const currentMonthDate = months.find((m) => m.month === month)?.$date;
|
||||
const isNextMonthBeyondMax =
|
||||
maxDate && currentMonthDate
|
||||
? new Date(
|
||||
currentMonthDate.getFullYear(),
|
||||
currentMonthDate.getMonth() + 1,
|
||||
1
|
||||
) > stripTime(maxDate)
|
||||
: false;
|
||||
const isPrevMonthBeforeMin =
|
||||
minDate && currentMonthDate
|
||||
? new Date(
|
||||
currentMonthDate.getFullYear(),
|
||||
currentMonthDate.getMonth(),
|
||||
1
|
||||
) <= stripTime(minDate)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -66,7 +100,10 @@ export function DayPicker(props: DayPickerProps) {
|
||||
<Button
|
||||
variant="icon"
|
||||
sx={{ p: 0 }}
|
||||
{...subtractOffset({ months: 1 })}
|
||||
{...subtractOffset(
|
||||
{ months: 1 },
|
||||
{ disabled: isPrevMonthBeforeMin }
|
||||
)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
@@ -83,9 +120,12 @@ export function DayPicker(props: DayPickerProps) {
|
||||
value={month}
|
||||
onChange={(e) => {
|
||||
const selectedOption = e.target.selectedOptions[0];
|
||||
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
|
||||
e
|
||||
const d = clampDate(
|
||||
new Date(selectedOption.dataset.date || ""),
|
||||
minDate,
|
||||
maxDate
|
||||
);
|
||||
setOffset(d)?.onClick?.(e as any);
|
||||
}}
|
||||
>
|
||||
{months.map((month) => (
|
||||
@@ -93,6 +133,7 @@ export function DayPicker(props: DayPickerProps) {
|
||||
key={month.month + year}
|
||||
value={month.month}
|
||||
data-date={month.$date.toDateString()}
|
||||
disabled={month.disabled}
|
||||
>
|
||||
{month.month}
|
||||
</option>
|
||||
@@ -111,9 +152,12 @@ export function DayPicker(props: DayPickerProps) {
|
||||
value={year}
|
||||
onChange={(e) => {
|
||||
const selectedOption = e.target.selectedOptions[0];
|
||||
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
|
||||
e
|
||||
const d = clampDate(
|
||||
new Date(selectedOption.dataset.date || ""),
|
||||
minDate,
|
||||
maxDate
|
||||
);
|
||||
setOffset(d)?.onClick?.(e as any);
|
||||
}}
|
||||
>
|
||||
{years.map((year) => (
|
||||
@@ -121,13 +165,23 @@ export function DayPicker(props: DayPickerProps) {
|
||||
key={year.year}
|
||||
value={year.year}
|
||||
data-date={year.$date.toDateString()}
|
||||
disabled={year.disabled}
|
||||
>
|
||||
{year.year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Flex>
|
||||
<Button variant="icon" sx={{ p: 0 }} {...addOffset({ months: 1 })}>
|
||||
<Button
|
||||
variant="icon"
|
||||
sx={{ p: 0 }}
|
||||
{...addOffset(
|
||||
{ months: 1 },
|
||||
{
|
||||
disabled: isNextMonthBeyondMax
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</Flex>
|
||||
@@ -193,3 +247,24 @@ export function DayPicker(props: DayPickerProps) {
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function clampDate(d: Date, minDate?: Date, maxDate?: Date) {
|
||||
/**
|
||||
* Strip time for accurate comparison
|
||||
*/
|
||||
const normalizedMaxDate = maxDate ? stripTime(maxDate) : null;
|
||||
const normalizedMinDate = minDate ? stripTime(minDate) : null;
|
||||
|
||||
if (normalizedMaxDate && d > normalizedMaxDate) {
|
||||
return normalizedMaxDate;
|
||||
}
|
||||
if (normalizedMinDate && d < normalizedMinDate) {
|
||||
return normalizedMinDate;
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
function stripTime(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Lock,
|
||||
NewTab,
|
||||
Note,
|
||||
NoteAdd,
|
||||
NoteRemove,
|
||||
Pin,
|
||||
Plus,
|
||||
@@ -98,7 +97,6 @@ type ToolButton = {
|
||||
export function EditorActionBar() {
|
||||
const { isMaximized, isFullscreen, hasNativeWindowControls } =
|
||||
useWindowControls();
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const activeTab = useEditorStore((store) => store.getActiveTab());
|
||||
const activeSession = useEditorStore((store) =>
|
||||
activeTab ? store.getSession(activeTab.sessionId) : undefined
|
||||
@@ -187,8 +185,7 @@ export function EditorActionBar() {
|
||||
activeSession &&
|
||||
activeSession.type !== "new" &&
|
||||
activeSession.type !== "locked" &&
|
||||
activeSession.type !== "conflicted" &&
|
||||
!isFocusMode,
|
||||
activeSession.type !== "conflicted",
|
||||
onClick: () => useEditorStore.getState().toggleProperties(),
|
||||
toggled: arePropertiesVisible
|
||||
},
|
||||
|
||||
@@ -50,7 +50,8 @@ import { FlexScrollContainer } from "../scroll-container";
|
||||
import Tiptap, { OnChangeHandler } from "./tiptap";
|
||||
import Header from "./header";
|
||||
import { Attachment } from "../icons";
|
||||
import { attachFiles, AttachmentProgress, insertAttachments } from "./picker";
|
||||
import { AttachmentProgress, insertAttachments } from "./picker";
|
||||
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
|
||||
import { useEditorManager } from "./manager";
|
||||
import {
|
||||
saveAttachment,
|
||||
@@ -617,13 +618,11 @@ export function Editor(props: EditorProps) {
|
||||
}
|
||||
|
||||
const mime = type === "file" ? "*/*" : "image/*";
|
||||
const attachments = await insertAttachments(mime);
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
|
||||
if (!attachments) return;
|
||||
for (const attachment of attachments) {
|
||||
editor?.attachFile(attachment);
|
||||
}
|
||||
await insertAttachments(mime, (attachments) => {
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
if (!editor) return;
|
||||
attachments.forEach((a) => editor?.attachFile(a));
|
||||
});
|
||||
}}
|
||||
onGetAttachmentData={async (attachment) => {
|
||||
logger.debug("Getting attachment data", {
|
||||
@@ -646,10 +645,14 @@ export function Editor(props: EditorProps) {
|
||||
return result;
|
||||
}}
|
||||
onAttachFiles={async (files) => {
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
const result = await attachFiles(files);
|
||||
if (!result) return;
|
||||
result.forEach((attachment) => editor?.attachFile(attachment));
|
||||
await AttachFilesDialog.show({
|
||||
files,
|
||||
onDone: (attachments) => {
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
if (!editor) return;
|
||||
attachments.forEach((a) => editor?.attachFile(a));
|
||||
}
|
||||
});
|
||||
}}
|
||||
onInsertInternalLink={async (attributes) => {
|
||||
const link = await NoteLinkingDialog.show({ attributes });
|
||||
@@ -799,17 +802,20 @@ function DropZone(props: DropZoneProps) {
|
||||
}}
|
||||
onDrop={async (e) => {
|
||||
try {
|
||||
const { activeEditorId, getEditor } = useEditorManager.getState();
|
||||
const editor = getEditor(activeEditorId || "")?.editor;
|
||||
if (!e.dataTransfer.files?.length || !editor) return;
|
||||
const { activeEditorId } = useEditorManager.getState();
|
||||
if (!e.dataTransfer.files?.length || !activeEditorId) return;
|
||||
|
||||
e.preventDefault();
|
||||
const attachments = await attachFiles(
|
||||
Array.from(e.dataTransfer.files)
|
||||
);
|
||||
for (const attachment of attachments || []) {
|
||||
editor.attachFile(attachment);
|
||||
}
|
||||
await AttachFilesDialog.show({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
onDone: (attachments) => {
|
||||
const editor = useEditorManager
|
||||
.getState()
|
||||
.getEditor(activeEditorId)?.editor;
|
||||
if (!editor) return;
|
||||
attachments.forEach((a) => editor?.attachFile(a));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e as Error, "Failed to attach file from drag and drop");
|
||||
showToast("error", strings.failedToAttachFile());
|
||||
|
||||
@@ -18,86 +18,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { SerializedKey } from "@notesnook/crypto";
|
||||
import { AppEventManager, AppEvents } from "../../common/app-events";
|
||||
import { db } from "../../common/db";
|
||||
import { TaskManager } from "../../common/task-manager";
|
||||
import { showToast } from "../../utils/toast";
|
||||
import { showFilePicker } from "../../utils/file-picker";
|
||||
import { Attachment } from "@notesnook/editor";
|
||||
import { ImagePickerDialog } from "../../dialogs/image-picker-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import {
|
||||
getUploadedFileSize,
|
||||
hashStream,
|
||||
writeEncryptedFile
|
||||
} from "../../interfaces/fs";
|
||||
import Config from "../../utils/config";
|
||||
import { compressImage, FileWithURI } from "../../utils/image-compressor";
|
||||
import { ImageCompressionOptions } from "../../stores/setting-store";
|
||||
import { checkFeature } from "../../common";
|
||||
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
export async function insertAttachments(type = "*/*") {
|
||||
export async function insertAttachments(
|
||||
type: string,
|
||||
onDone: (attachments: Attachment[]) => void
|
||||
): Promise<void> {
|
||||
const files = await showFilePicker({
|
||||
acceptedFileTypes: type || "*/*",
|
||||
multiple: true
|
||||
});
|
||||
if (!files) return;
|
||||
return await attachFiles(files, type === "*/*");
|
||||
}
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
export async function attachFiles(
|
||||
files: File[],
|
||||
skipSpecialImageHandling = false
|
||||
) {
|
||||
let images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
|
||||
"imageCompression",
|
||||
ImageCompressionOptions.ASK_EVERY_TIME
|
||||
);
|
||||
|
||||
switch (imageCompressionConfig) {
|
||||
case ImageCompressionOptions.ENABLE: {
|
||||
const compressedImages: FileWithURI[] = [];
|
||||
for (const image of images) {
|
||||
const compressed = await compressImage(image, {
|
||||
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
|
||||
width: (naturalWidth) => naturalWidth,
|
||||
height: (_, naturalHeight) => naturalHeight,
|
||||
resize: "contain",
|
||||
quality: 0.7
|
||||
});
|
||||
compressedImages.push(
|
||||
new FileWithURI([compressed], image.name, {
|
||||
lastModified: image.lastModified,
|
||||
type: image.type
|
||||
})
|
||||
);
|
||||
}
|
||||
images = compressedImages;
|
||||
break;
|
||||
}
|
||||
case ImageCompressionOptions.DISABLE:
|
||||
break;
|
||||
default:
|
||||
images =
|
||||
images.length > 0
|
||||
? (await ImagePickerDialog.show({
|
||||
images
|
||||
})) || []
|
||||
: [];
|
||||
}
|
||||
|
||||
const documents = files.filter((f) => !f.type.startsWith("image/"));
|
||||
const attachments: Attachment[] = [];
|
||||
for (const file of [...images, ...documents]) {
|
||||
const attachment =
|
||||
!skipSpecialImageHandling && file.type.startsWith("image/")
|
||||
? await pickImage(file)
|
||||
: await pickFile(file);
|
||||
if (!attachment) continue;
|
||||
attachments.push(attachment);
|
||||
}
|
||||
return attachments;
|
||||
await AttachFilesDialog.show({
|
||||
files,
|
||||
skipSpecialImageHandling: type === "*/*",
|
||||
onDone
|
||||
});
|
||||
}
|
||||
|
||||
export async function reuploadAttachment(
|
||||
@@ -109,9 +58,16 @@ export async function reuploadAttachment(
|
||||
});
|
||||
if (!selectedFile) return;
|
||||
|
||||
if (
|
||||
!(await checkFeature("fileSize", {
|
||||
value: selectedFile.size
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options: AddAttachmentOptions = {
|
||||
expectedFileHash,
|
||||
showProgress: false,
|
||||
forceWrite: true
|
||||
};
|
||||
|
||||
@@ -124,6 +80,72 @@ export async function reuploadAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
type AttachFilesMessage =
|
||||
| { type: "compressing"; index: number }
|
||||
| { type: "encrypting"; index: number }
|
||||
| {
|
||||
type: "done";
|
||||
index: number;
|
||||
attachment: Attachment | undefined;
|
||||
}
|
||||
| { type: "error"; index: number; error: string };
|
||||
|
||||
export async function* attachFiles(
|
||||
files: File[],
|
||||
shouldCompress: boolean[],
|
||||
skipSpecialImageHandling = false
|
||||
): AsyncGenerator<AttachFilesMessage> {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let file = files[i];
|
||||
const shouldCompressFile = shouldCompress[i];
|
||||
|
||||
if (shouldCompressFile) {
|
||||
yield { type: "compressing", index: i };
|
||||
try {
|
||||
const compressed = await compressImage(file, {
|
||||
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
|
||||
width: (naturalWidth) => naturalWidth,
|
||||
height: (_, naturalHeight) => naturalHeight,
|
||||
resize: "contain",
|
||||
quality: 0.7
|
||||
});
|
||||
file = new FileWithURI([compressed], file.name, {
|
||||
lastModified: file.lastModified,
|
||||
type: file.type
|
||||
});
|
||||
} catch (e) {
|
||||
yield {
|
||||
type: "error",
|
||||
index: i,
|
||||
error: (e as Error).message || strings.compressionFailed()
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "encrypting", index: i };
|
||||
|
||||
try {
|
||||
const allowed = await checkFeature("fileSize", {
|
||||
value: file.size,
|
||||
type: "toast"
|
||||
});
|
||||
if (!allowed) {
|
||||
throw new Error(strings.fileSizeLimitExceededPleaseUpgrade());
|
||||
}
|
||||
|
||||
const attachment =
|
||||
!skipSpecialImageHandling && file.type.startsWith("image/")
|
||||
? await pickImage(file)
|
||||
: await pickFile(file);
|
||||
|
||||
yield { type: "done", index: i, attachment: attachment || undefined };
|
||||
} catch (e) {
|
||||
yield { type: "error", index: i, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns
|
||||
@@ -133,8 +155,6 @@ async function pickFile(
|
||||
options?: AddAttachmentOptions
|
||||
): Promise<Attachment | undefined> {
|
||||
try {
|
||||
if (!(await checkFeature("fileSize", { value: file.size }))) return;
|
||||
|
||||
const hash = await addAttachment(file, options);
|
||||
return {
|
||||
type: "file",
|
||||
@@ -158,8 +178,6 @@ async function pickImage(
|
||||
options?: AddAttachmentOptions
|
||||
): Promise<Attachment | undefined> {
|
||||
try {
|
||||
if (!(await checkFeature("fileSize", { value: file.size }))) return;
|
||||
|
||||
const hash = await addAttachment(file, options);
|
||||
const dimensions = await getImageDimensions(file);
|
||||
return {
|
||||
@@ -171,6 +189,7 @@ async function pickImage(
|
||||
...dimensions
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast("error", (e as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -190,7 +209,6 @@ export type AttachmentProgress = {
|
||||
|
||||
type AddAttachmentOptions = {
|
||||
expectedFileHash?: string;
|
||||
showProgress?: boolean;
|
||||
forceWrite?: boolean;
|
||||
};
|
||||
|
||||
@@ -198,80 +216,46 @@ async function addAttachment(
|
||||
file: File,
|
||||
options: AddAttachmentOptions = {}
|
||||
): Promise<string> {
|
||||
const { expectedFileHash, showProgress = true } = options;
|
||||
const { expectedFileHash } = options;
|
||||
let forceWrite = options.forceWrite;
|
||||
|
||||
const action = async () => {
|
||||
const reader = file.stream().getReader();
|
||||
const { hash, type: hashType } = await hashStream(reader);
|
||||
reader.releaseLock();
|
||||
const reader = file.stream().getReader();
|
||||
const { hash, type: hashType } = await hashStream(reader);
|
||||
reader.releaseLock();
|
||||
|
||||
if (expectedFileHash && hash !== expectedFileHash)
|
||||
throw new Error(
|
||||
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
|
||||
);
|
||||
if (expectedFileHash && hash !== expectedFileHash)
|
||||
throw new Error(
|
||||
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
|
||||
);
|
||||
|
||||
const exists = await db.attachments.attachment(hash);
|
||||
if (!forceWrite && exists) {
|
||||
forceWrite = (await getUploadedFileSize(hash)) === 0;
|
||||
const exists = await db.attachments.attachment(hash);
|
||||
if (!forceWrite && exists) {
|
||||
forceWrite = (await getUploadedFileSize(hash)) === 0;
|
||||
}
|
||||
|
||||
if (forceWrite || !exists) {
|
||||
if (forceWrite && exists) {
|
||||
if (!(await db.fs().deleteFile(hash, false)))
|
||||
throw new Error("Failed to delete attachment from server.");
|
||||
await db.attachments.reset(exists.id);
|
||||
}
|
||||
|
||||
if (forceWrite || !exists) {
|
||||
if (forceWrite && exists) {
|
||||
if (!(await db.fs().deleteFile(hash, false)))
|
||||
throw new Error("Failed to delete attachment from server.");
|
||||
await db.attachments.reset(exists.id);
|
||||
}
|
||||
const key: SerializedKey = await getEncryptionKey();
|
||||
|
||||
const key: SerializedKey = await getEncryptionKey();
|
||||
const output = await writeEncryptedFile(file, key, hash);
|
||||
if (!output) throw new Error("Could not encrypt file.");
|
||||
|
||||
const output = await writeEncryptedFile(file, key, hash);
|
||||
if (!output) throw new Error("Could not encrypt file.");
|
||||
await db.attachments.add({
|
||||
...output,
|
||||
hash,
|
||||
hashType,
|
||||
filename: exists?.filename || file.name,
|
||||
mimeType: exists?.type || file.type,
|
||||
key
|
||||
});
|
||||
}
|
||||
|
||||
await db.attachments.add({
|
||||
...output,
|
||||
hash,
|
||||
hashType,
|
||||
filename: exists?.filename || file.name,
|
||||
mimeType: exists?.type || file.type,
|
||||
key
|
||||
});
|
||||
}
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
const result = showProgress
|
||||
? await withProgress(file, action)
|
||||
: await action();
|
||||
|
||||
if (result instanceof Error) throw result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function withProgress<T>(
|
||||
file: File,
|
||||
action: () => Promise<T>
|
||||
): Promise<T | Error> {
|
||||
return TaskManager.startTask({
|
||||
type: "modal",
|
||||
title: strings.encryptingAttachment(),
|
||||
subtitle: strings.encryptingAttachmentDesc(),
|
||||
action: (report) => {
|
||||
const event = AppEventManager.subscribe(
|
||||
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
|
||||
({ type, total, loaded }: AttachmentProgress) => {
|
||||
if (type !== "encrypt") return;
|
||||
report({
|
||||
current: Math.round((loaded / total) * 100),
|
||||
total: 100,
|
||||
text: file.name
|
||||
});
|
||||
}
|
||||
);
|
||||
return action().finally(() => event.unsubscribe());
|
||||
}
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
|
||||
function getImageDimensions(file: File) {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useStore as useThemeStore } from "../../stores/theme-store";
|
||||
import { writeToClipboard } from "../../utils/clipboard";
|
||||
import { useEditorStore } from "../../stores/editor-store";
|
||||
import { DayFormat, parseInternalLink } from "@notesnook/core";
|
||||
import { desktop } from "../../common/desktop-bridge";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import useMobile from "../../hooks/use-mobile";
|
||||
import useTablet from "../../hooks/use-tablet";
|
||||
@@ -68,6 +69,7 @@ import { showFeatureNotAllowedToast } from "../../common/toasts";
|
||||
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
|
||||
import { ConfirmDialog } from "../../dialogs/confirm";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { showToast } from "../../utils/toast";
|
||||
|
||||
export type OnChangeHandler = (
|
||||
content: () => string,
|
||||
@@ -421,13 +423,32 @@ function TipTap(props: TipTapProps) {
|
||||
previewAttachment: onPreviewAttachment,
|
||||
createInternalLink: onInsertInternalLink,
|
||||
getAttachmentData: onGetAttachmentData,
|
||||
openLink: (url, openInNewTab) => {
|
||||
openLink: async (url, openInNewTab) => {
|
||||
const link = parseInternalLink(url);
|
||||
if (link && link.type === "note") {
|
||||
useEditorStore.getState().openSession(link.id, {
|
||||
activeBlockId: link.params?.blockId || undefined,
|
||||
openInNewTab: openInNewTab
|
||||
});
|
||||
} else if (url.startsWith("file:")) {
|
||||
if (!IS_DESKTOP_APP) {
|
||||
showToast("error", strings.cantOpenFileLinksInBrowsers());
|
||||
return;
|
||||
}
|
||||
|
||||
const path = new URL(url).pathname;
|
||||
const ok = await ConfirmDialog.show({
|
||||
title: strings.openingLocalFile(),
|
||||
message: strings.openingLocalFileDesc(path),
|
||||
positiveButtonText: strings.open(),
|
||||
negativeButtonText: strings.cancel()
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
await desktop?.integration.openPath.query({
|
||||
type: "path",
|
||||
link: decodeURIComponent(path)
|
||||
});
|
||||
} else window.open(url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useStore as useSearchStore } from "../../stores/search-store";
|
||||
import type { Context } from "../list-container/types";
|
||||
|
||||
const groupByToTitleMap = {
|
||||
none: "None",
|
||||
@@ -61,6 +62,7 @@ type GroupingMenuOptions = {
|
||||
groupingKey: GroupingKey;
|
||||
refresh: () => void;
|
||||
isSearching?: boolean;
|
||||
context?: Context;
|
||||
};
|
||||
|
||||
const groupByMenu: (options: GroupingMenuOptions) => MenuItem | null = (
|
||||
@@ -192,6 +194,39 @@ export function showSortMenu(groupingKey: GroupingKey, refresh: () => void) {
|
||||
);
|
||||
}
|
||||
|
||||
function getGroupOptions(
|
||||
context: Context | undefined,
|
||||
isSearching: boolean | undefined,
|
||||
groupingKey: GroupingKey
|
||||
): GroupOptions {
|
||||
return isSearching
|
||||
? db.settings.getGroupOptions("search")
|
||||
: context?.type === "notebook" ||
|
||||
context?.type === "tag" ||
|
||||
context?.type === "color"
|
||||
? db.settings.getGroupOptionsById(context.id, context.type)
|
||||
: db.settings.getGroupOptions(groupingKey);
|
||||
}
|
||||
|
||||
async function setGroupOptions(
|
||||
options: GroupingMenuOptions,
|
||||
groupOptions: GroupOptions
|
||||
) {
|
||||
if (
|
||||
options.context?.type === "notebook" ||
|
||||
options.context?.type === "tag" ||
|
||||
options.context?.type === "color"
|
||||
) {
|
||||
await db.settings.setGroupOptionsById(
|
||||
options.context.id,
|
||||
options.context.type,
|
||||
groupOptions
|
||||
);
|
||||
} else {
|
||||
await db.settings.setGroupOptions(options.groupingKey, groupOptions);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeGroupOptions(
|
||||
options: GroupingMenuOptions,
|
||||
item: Omit<MenuButtonItem, "type">
|
||||
@@ -207,7 +242,9 @@ async function changeGroupOptions(
|
||||
? "dateModified"
|
||||
: groupOptions.sortBy;
|
||||
}
|
||||
await db.settings.setGroupOptions(options.groupingKey, groupOptions);
|
||||
|
||||
await setGroupOptions(options, groupOptions);
|
||||
|
||||
if (options.groupingKey === "search")
|
||||
useSearchStore.setState({ sortOptions: groupOptions });
|
||||
options.refresh();
|
||||
@@ -235,6 +272,7 @@ type GroupHeaderProps = {
|
||||
onSelectGroup: () => void;
|
||||
isFocused: boolean;
|
||||
isSearching?: boolean;
|
||||
context?: Context;
|
||||
};
|
||||
function GroupHeader(props: GroupHeaderProps) {
|
||||
const {
|
||||
@@ -246,10 +284,12 @@ function GroupHeader(props: GroupHeaderProps) {
|
||||
refresh,
|
||||
onSelectGroup,
|
||||
isFocused,
|
||||
isSearching
|
||||
isSearching,
|
||||
context
|
||||
} = props;
|
||||
|
||||
const [groupOptions, setGroupOptions] = useState(
|
||||
db.settings.getGroupOptions(isSearching ? "search" : groupingKey)
|
||||
getGroupOptions(context, isSearching, groupingKey)
|
||||
);
|
||||
const groupHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const { openMenu, target } = useMenuTrigger();
|
||||
@@ -359,8 +399,10 @@ function GroupHeader(props: GroupHeaderProps) {
|
||||
groupByToTitleMap[groupOptions.groupBy || "default"]
|
||||
}`}
|
||||
onClick={() => {
|
||||
const groupOptions = db.settings.getGroupOptions(
|
||||
isSearching ? "search" : groupingKey
|
||||
const groupOptions = getGroupOptions(
|
||||
context,
|
||||
isSearching,
|
||||
groupingKey
|
||||
);
|
||||
setGroupOptions(groupOptions);
|
||||
|
||||
@@ -368,7 +410,8 @@ function GroupHeader(props: GroupHeaderProps) {
|
||||
groupingKey: isSearching ? "search" : groupingKey,
|
||||
groupOptions,
|
||||
refresh,
|
||||
isSearching
|
||||
isSearching,
|
||||
context
|
||||
};
|
||||
const groupBy = groupByMenu({
|
||||
...menuOptions,
|
||||
|
||||
@@ -229,7 +229,8 @@ import {
|
||||
mdiArrowUp,
|
||||
mdiInbox,
|
||||
mdiConsoleLine,
|
||||
mdiDeleteSweepOutline
|
||||
mdiDeleteSweepOutline,
|
||||
mdiCloseCircle
|
||||
} from "@mdi/js";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Theme } from "@notesnook/theme";
|
||||
@@ -586,3 +587,4 @@ export const HamburgerMenu = createIcon(mdiMenu);
|
||||
export const ArrowUp = createIcon(mdiArrowUp);
|
||||
export const Inbox = createIcon(mdiInbox);
|
||||
export const ClearTrash = createIcon(mdiDeleteSweepOutline);
|
||||
export const CloseCircle = createIcon(mdiCloseCircle);
|
||||
|
||||
@@ -370,6 +370,7 @@ function ItemRenderer({
|
||||
title={resolvedItem.group.title}
|
||||
isFocused={index === focusedGroupIndex}
|
||||
index={index}
|
||||
context={itemContext}
|
||||
onSelectGroup={async () => {
|
||||
if (!items.groups) return;
|
||||
|
||||
|
||||
@@ -228,7 +228,13 @@ const tabs: NavigationTabItem[] = [
|
||||
}
|
||||
] as const;
|
||||
|
||||
function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
|
||||
function NavigationMenu({
|
||||
onExpand,
|
||||
canExpand
|
||||
}: {
|
||||
onExpand?: () => void;
|
||||
canExpand: boolean;
|
||||
}) {
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const navigationTab = useAppStore((store) => store.navigationTab);
|
||||
const setNavigationTab = useAppStore((store) => store.setNavigationTab);
|
||||
@@ -352,6 +358,7 @@ function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
|
||||
sx={{ p: 1, bg: "transparent" }}
|
||||
onClick={onExpand}
|
||||
title={strings.expandSidebar()}
|
||||
disabled={!canExpand}
|
||||
>
|
||||
<ExpandSidebar size={13} color="icon" />
|
||||
</Button>
|
||||
|
||||
@@ -104,6 +104,7 @@ import ListItem from "../list-item";
|
||||
import { PublishDialog } from "../publish-view";
|
||||
import TimeAgo from "../time-ago";
|
||||
import { NoteExpiryDateDialog } from "../../dialogs/note-expiry-date-dialog";
|
||||
import { withFeatureCheck } from "../../common";
|
||||
|
||||
type NoteProps = NoteResolvedData & {
|
||||
item: NoteType;
|
||||
@@ -665,11 +666,11 @@ export const noteMenuItems: (
|
||||
title: strings.setExpiry(),
|
||||
icon: Destruct.path,
|
||||
premium: !features.expiringNotes.isAllowed,
|
||||
onClick: async () => {
|
||||
onClick: withFeatureCheck(features.expiringNotes, async () => {
|
||||
await NoteExpiryDateDialog.show({
|
||||
noteId: note.id
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
DefaultEditorSession
|
||||
} from "../../stores/editor-store";
|
||||
import { db } from "../../common/db";
|
||||
import { useStore as useAppStore } from "../../stores/app-store";
|
||||
import { useStore as useAttachmentStore } from "../../stores/attachment-store";
|
||||
import { store as noteStore } from "../../stores/note-store";
|
||||
import Toggle from "./toggle";
|
||||
@@ -109,7 +108,6 @@ type EditorPropertiesProps = {
|
||||
};
|
||||
function EditorProperties(props: EditorPropertiesProps) {
|
||||
const toggleProperties = useEditorStore((store) => store.toggleProperties);
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const dateFormat = useSettingStore((store) => store.dateFormat);
|
||||
const timeFormat = useSettingStore((store) => store.timeFormat);
|
||||
const metadataItems = [
|
||||
@@ -140,7 +138,8 @@ function EditorProperties(props: EditorPropertiesProps) {
|
||||
"diff"
|
||||
])
|
||||
);
|
||||
if (isFocusMode || !session) return null;
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
|
||||
@@ -60,6 +60,7 @@ function PublishView(props: PublishViewProps) {
|
||||
const publishNote = useStore((store) => store.publish);
|
||||
const unpublishNote = useStore((store) => store.unpublish);
|
||||
const [monograph, setMonograph] = useState(props.monograph);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const monographAnalytics = useIsFeatureAvailable("monographAnalytics");
|
||||
const analytics = usePromise(async () => {
|
||||
if (!monographAnalytics?.isAllowed || !monograph) return { totalViews: 0 };
|
||||
@@ -110,12 +111,15 @@ function PublishView(props: PublishViewProps) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="copyPublishLink"
|
||||
sx={{ flexShrink: 0, m: 0 }}
|
||||
sx={{ flexShrink: 0, m: 0, color: copied ? "accent" : "initial" }}
|
||||
onClick={() => {
|
||||
writeText(`${hosts.MONOGRAPH_HOST}/${monograph?.id}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
disabled={copied}
|
||||
>
|
||||
{strings.copy()}
|
||||
{copied ? strings.copied() : strings.copy()}
|
||||
</Button>
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const AddNotebookDialog = DialogManager.register(
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (!title.current.trim())
|
||||
return showToast("error", strings.allFieldsRequired());
|
||||
return showToast("error", strings.titleIsRequired());
|
||||
|
||||
const id = await db.notebooks.add({
|
||||
id: props.notebook?.id,
|
||||
|
||||
@@ -48,6 +48,8 @@ import Skeleton from "react-loading-skeleton";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
const MAX_DATE = dayjs().add(99, "year").endOf("year").toDate();
|
||||
|
||||
export type AddReminderDialogProps = BaseDialogProps<boolean> & {
|
||||
reminder?: Reminder;
|
||||
note?: Note;
|
||||
@@ -200,6 +202,14 @@ export const AddReminderDialog = DialogManager.register(
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== Modes.REPEAT && date.isAfter(MAX_DATE)) {
|
||||
showToast(
|
||||
"error",
|
||||
strings.maximumReminderDate(getFormattedDate(MAX_DATE, "date"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = await db.reminders.add({
|
||||
id: reminder?.id,
|
||||
recurringMode,
|
||||
@@ -431,7 +441,7 @@ export const AddReminderDialog = DialogManager.register(
|
||||
}}
|
||||
selected={dayjs(date).toDate()}
|
||||
minDate={new Date()}
|
||||
maxDate={new Date(new Date().getFullYear() + 99, 11, 31)}
|
||||
maxDate={MAX_DATE}
|
||||
onSelect={(day) => {
|
||||
if (!day) return;
|
||||
const date = getFormattedDate(day, "date");
|
||||
|
||||
373
apps/web/src/dialogs/attach-files-dialog.tsx
Normal file
373
apps/web/src/dialogs/attach-files-dialog.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Dialog from "../components/dialog";
|
||||
import { ScrollContainer } from "@notesnook/ui";
|
||||
import { Box, Flex, Image, Switch, Text } from "@theme-ui/components";
|
||||
import { formatBytes } from "@notesnook/common";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { checkFeature } from "../common";
|
||||
import { AppEventManager, AppEvents } from "../common/app-events";
|
||||
import { attachFiles, AttachmentProgress } from "../components/editor/picker";
|
||||
import Config from "../utils/config";
|
||||
import { ImageCompressionOptions } from "../stores/setting-store";
|
||||
import { Attachment } from "@notesnook/editor";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loading,
|
||||
File as FileIcon,
|
||||
CloseCircle
|
||||
} from "../components/icons";
|
||||
|
||||
type FileStatus = "pending" | "compressing" | "encrypting" | "done" | "error";
|
||||
|
||||
type FileState = {
|
||||
file: File;
|
||||
status: FileStatus;
|
||||
progress: number;
|
||||
error?: string;
|
||||
compress: boolean;
|
||||
};
|
||||
|
||||
type AttachFilesDialogProps = BaseDialogProps<false> & {
|
||||
files: File[];
|
||||
skipSpecialImageHandling?: boolean;
|
||||
onDone: (attachments: Attachment[]) => void;
|
||||
};
|
||||
|
||||
export const AttachFilesDialog = DialogManager.register(
|
||||
function AttachFilesDialog({
|
||||
files,
|
||||
skipSpecialImageHandling,
|
||||
onDone,
|
||||
onClose
|
||||
}: AttachFilesDialogProps) {
|
||||
const hasImages = files.some((f) => f.type.startsWith("image/"));
|
||||
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
|
||||
"imageCompression",
|
||||
ImageCompressionOptions.ASK_EVERY_TIME
|
||||
);
|
||||
const [fileStates, setFileStates] = useState<FileState[]>(() =>
|
||||
files.map((file) => ({
|
||||
file,
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
compress: file.type.startsWith("image/")
|
||||
? imageCompressionConfig !== ImageCompressionOptions.DISABLE
|
||||
: false
|
||||
}))
|
||||
);
|
||||
const [showCompressionPrompt, setShowCompressionPrompt] = useState(
|
||||
hasImages &&
|
||||
imageCompressionConfig === ImageCompressionOptions.ASK_EVERY_TIME
|
||||
);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const event = AppEventManager.subscribe(
|
||||
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
|
||||
({ type, total, loaded }: AttachmentProgress) => {
|
||||
if (type !== "encrypt") return;
|
||||
|
||||
setFileStates((prev) =>
|
||||
prev.map((s) => {
|
||||
/**
|
||||
* only one file is encrypted at a time, so we can just update progress of the state with "encrypting" status
|
||||
*/
|
||||
if (s.status !== "encrypting") return s;
|
||||
|
||||
return {
|
||||
...s,
|
||||
progress: Math.round((loaded / total) * 100)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
event.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCompressionPrompt || processingRef.current) return;
|
||||
|
||||
processingRef.current = true;
|
||||
|
||||
const shouldCompress: boolean[] = fileStates.map((s) => !!s.compress);
|
||||
|
||||
(async () => {
|
||||
const attachments: Attachment[] = [];
|
||||
let hasError = false;
|
||||
|
||||
for await (const message of attachFiles(
|
||||
files,
|
||||
shouldCompress,
|
||||
skipSpecialImageHandling
|
||||
)) {
|
||||
const { index } = message;
|
||||
switch (message.type) {
|
||||
case "compressing":
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, status: "compressing" as FileStatus }
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "encrypting":
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, status: "encrypting" as FileStatus, progress: 0 }
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "done":
|
||||
if (message.attachment) attachments.push(message.attachment);
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? {
|
||||
...s,
|
||||
status: "done" as FileStatus
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
hasError = true;
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? {
|
||||
...s,
|
||||
status: "error" as FileStatus,
|
||||
error: message.error
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onDone(attachments);
|
||||
if (files.length === 1 && !hasError) onClose(false);
|
||||
})();
|
||||
}, [showCompressionPrompt]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={
|
||||
showCompressionPrompt
|
||||
? strings.imageCompression()
|
||||
: strings.attachingFiles()
|
||||
}
|
||||
description={
|
||||
showCompressionPrompt ? strings.imageCompressionDesc() : ""
|
||||
}
|
||||
onClose={() => onClose(false)}
|
||||
width={500}
|
||||
positiveButton={
|
||||
showCompressionPrompt
|
||||
? {
|
||||
text: strings.done(),
|
||||
onClick: () => setShowCompressionPrompt(false)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
negativeButton={{
|
||||
text: strings.close(),
|
||||
onClick: () => onClose(false)
|
||||
}}
|
||||
>
|
||||
<ScrollContainer
|
||||
style={{
|
||||
maxHeight: 350,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
{fileStates.map((state, index) => (
|
||||
<FileRow
|
||||
key={`${state.file.name}-${index}`}
|
||||
state={state}
|
||||
showDivider={fileStates.length > 1}
|
||||
showCompressionToggle={showCompressionPrompt}
|
||||
onToggleCompress={async () => {
|
||||
if (
|
||||
!(await checkFeature("fullQualityImages", { type: "toast" }))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, idx) =>
|
||||
idx === index ? { ...s, compress: !s.compress } : s
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollContainer>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function FileRow({
|
||||
state,
|
||||
showDivider,
|
||||
showCompressionToggle,
|
||||
onToggleCompress
|
||||
}: {
|
||||
state: FileState;
|
||||
showDivider?: boolean;
|
||||
showCompressionToggle?: boolean;
|
||||
onToggleCompress?: () => void;
|
||||
}) {
|
||||
const { file, status, progress, error, compress } = state;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const thumbnail = useMemo(
|
||||
() => (isImage ? URL.createObjectURL(file) : undefined),
|
||||
[file, isImage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (thumbnail) URL.revokeObjectURL(thumbnail);
|
||||
};
|
||||
}, [thumbnail]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
py: 1,
|
||||
px: 1,
|
||||
gap: 2,
|
||||
borderBottom: showDivider ? "1px solid var(--border)" : undefined
|
||||
}}
|
||||
>
|
||||
{thumbnail ? (
|
||||
<Image
|
||||
src={thumbnail}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: "cover",
|
||||
borderRadius: "default",
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
bg: "background-secondary",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
>
|
||||
<FileIcon size={20} color="icon" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex sx={{ flex: 1, flexDirection: "column", minWidth: 0 }}>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
|
||||
{formatBytes(file.size)}
|
||||
{status === "compressing"
|
||||
? ` — ${strings.compressing()}...`
|
||||
: status === "encrypting"
|
||||
? ` — ${strings.encrypting()} ${progress}%`
|
||||
: status === "error"
|
||||
? ` — ${error}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex sx={{ flexShrink: 0, alignItems: "center" }}>
|
||||
{showCompressionToggle && isImage ? (
|
||||
<Switch
|
||||
sx={{
|
||||
m: 0,
|
||||
bg: compress ? "accent" : "icon-secondary",
|
||||
flexShrink: 0,
|
||||
scale: 0.75
|
||||
}}
|
||||
checked={compress}
|
||||
onChange={onToggleCompress}
|
||||
/>
|
||||
) : showCompressionToggle && !isImage ? (
|
||||
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
|
||||
N/A
|
||||
</Text>
|
||||
) : status === "done" ? (
|
||||
<CheckCircle size={20} color="accent" />
|
||||
) : status === "error" ? (
|
||||
<CloseCircle size={20} color="accent-error" />
|
||||
) : status === "encrypting" || status === "compressing" ? (
|
||||
<Flex sx={{ alignItems: "center", gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 4,
|
||||
bg: "border",
|
||||
borderRadius: "full",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${status === "compressing" ? 50 : progress}%`,
|
||||
height: "100%",
|
||||
bg: "accent",
|
||||
transition: "width 0.2s ease"
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
) : (
|
||||
<Loading size={16} color="icon" />
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export const ConfirmDialog = DialogManager.register(function ConfirmDialog(
|
||||
<Text
|
||||
as="div"
|
||||
variant="body"
|
||||
sx={{ overflowWrap: "break-word" }}
|
||||
dangerouslySetInnerHTML={{ __html: mdToHtml(message) }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user