mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 03:29:21 +02:00
Compare commits
122 Commits
mobile/rel
...
v3.3.21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb43d80e05 | ||
|
|
52d7d90b68 | ||
|
|
aab6a36067 | ||
|
|
c7e0cd35e7 | ||
|
|
f1124c4112 | ||
|
|
a1575ab5ea | ||
|
|
d31eaef6d4 | ||
|
|
930e31d109 | ||
|
|
2a6cc90592 | ||
|
|
a13962f8c9 | ||
|
|
a4c8259a02 | ||
|
|
fc93a39164 | ||
|
|
9b588c50bf | ||
|
|
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 | ||
|
|
b8fca5cd98 | ||
|
|
4d87155fdd | ||
|
|
ed38bb6299 | ||
|
|
7c9e3e79df | ||
|
|
43b90070a9 | ||
|
|
04f6e1068b | ||
|
|
9bea4adcf9 | ||
|
|
a5790de23c | ||
|
|
eb4edeea8f | ||
|
|
e3ccd504da | ||
|
|
4627d7d5df | ||
|
|
f216c1b65d | ||
|
|
4692ba7183 | ||
|
|
9c497fd2bb | ||
|
|
ede900fdbd | ||
|
|
f18db748e0 |
51
.github/workflows/desktop.preview.yml
vendored
51
.github/workflows/desktop.preview.yml
vendored
@@ -101,7 +101,6 @@ jobs:
|
||||
|
||||
- name: Build dmg
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.mac_certs }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.mac_certs_password }}
|
||||
APPLE_API_KEY: ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
|
||||
@@ -153,8 +152,6 @@ jobs:
|
||||
run: npm run tx @notesnook/desktop:release
|
||||
|
||||
- name: Build AppImage
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: yarn electron-builder --config=electron-builder.config.js --linux AppImage:x64 -p never
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
@@ -165,6 +162,49 @@ jobs:
|
||||
name: linux-x64-build
|
||||
path: apps/desktop/output/*.AppImage
|
||||
|
||||
build-linux-arm64:
|
||||
name: Build for Linux arm64
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04-arm
|
||||
outputs:
|
||||
linux-arm64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Download build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: ./apps/web/build
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=desktop
|
||||
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu arm64 sqlite-better-trigram
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
run: npm run tx @notesnook/desktop:release
|
||||
|
||||
- name: Build AppImage
|
||||
run: yarn electron-builder --config=electron-builder.config.js --linux AppImage:arm64 -p never
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
id: artifact-upload-step
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-arm64-build
|
||||
path: apps/desktop/output/*.AppImage
|
||||
|
||||
build-windows:
|
||||
name: Build for Windows
|
||||
needs: build
|
||||
@@ -216,7 +256,8 @@ jobs:
|
||||
|
||||
post-pr-comment:
|
||||
name: Post PR comment with preview URLs
|
||||
needs: [build, build-macos, build-linux-x64, build-windows]
|
||||
needs:
|
||||
[build, build-macos, build-linux-x64, build-linux-arm64, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post or update PR comment
|
||||
@@ -224,6 +265,7 @@ jobs:
|
||||
env:
|
||||
macos_artifact_url: ${{ needs.build-macos.outputs.macos-artifact-url }}
|
||||
linux_x64_artifact_url: ${{ needs.build-linux-x64.outputs.linux-x64-artifact-url }}
|
||||
linux_arm64_artifact_url: ${{ needs.build-linux-arm64.outputs.linux-arm64-artifact-url }}
|
||||
windows_artifact_url: ${{ needs.build-windows.outputs.windows-artifact-url }}
|
||||
with:
|
||||
script: |
|
||||
@@ -232,6 +274,7 @@ jobs:
|
||||
const previewUrl = [
|
||||
{ platform: 'macOS', url: process.env.macos_artifact_url },
|
||||
{ platform: 'Linux x64', url: process.env.linux_x64_artifact_url },
|
||||
{ platform: 'Linux arm64', url: process.env.linux_arm64_artifact_url },
|
||||
{ platform: 'Windows x64', url: process.env.windows_artifact_url },
|
||||
].filter(u => u.url).map(u => `- [${u.platform}](${u.url})`).join('\n');
|
||||
const body = `${marker}\n**Desktop Previews**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
|
||||
|
||||
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
|
||||
|
||||
5
.github/workflows/ios.publish.yml
vendored
5
.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
|
||||
|
||||
@@ -198,6 +198,9 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
},
|
||||
toolsets: {
|
||||
appimage: "1.0.2"
|
||||
},
|
||||
snap: {
|
||||
autoStart: false,
|
||||
confinement: "strict",
|
||||
|
||||
2132
apps/desktop/package-lock.json
generated
2132
apps/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"name": "@notesnook/desktop",
|
||||
"productName": "Notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "3.3.14",
|
||||
"version": "3.3.21",
|
||||
"appAppleId": "1544027013",
|
||||
"private": true,
|
||||
"main": "./dist/cjs/index.js",
|
||||
@@ -48,7 +48,7 @@
|
||||
"@types/yargs": "^17.0.33",
|
||||
"chokidar": "^4.0.3",
|
||||
"electron": "^37.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-builder": "^26.8.1",
|
||||
"esbuild": "0.21.5",
|
||||
"node-abi": "^4.5.0",
|
||||
"node-gyp-build": "^4.8.4",
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -178,7 +180,9 @@ export const osIntegrationRouter = t.router({
|
||||
})
|
||||
});
|
||||
notification.show();
|
||||
if (input.urgency === "critical") {
|
||||
if (input.urgency === "critical" && process.platform !== "linux") {
|
||||
// due to an Electron bug in versions below 40.x, shell.beep() causes a segfault on Linux.
|
||||
// TODO: Remove when migrating to Electron 40.x or newer.
|
||||
shell.beep();
|
||||
}
|
||||
|
||||
@@ -189,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
|
||||
@@ -296,3 +319,7 @@ function toMenuItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapPath(path: string, maxLineLength = 100): string {
|
||||
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
|
||||
}
|
||||
|
||||
@@ -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 3105
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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,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 { GroupHeader, GroupOptions, ItemType } from "@notesnook/core";
|
||||
import {
|
||||
GroupHeader,
|
||||
GroupingKey,
|
||||
GroupOptions,
|
||||
ItemType
|
||||
} from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React from "react";
|
||||
@@ -40,7 +45,9 @@ type SectionHeaderProps = {
|
||||
color?: string;
|
||||
screen?: RouteName;
|
||||
groupOptions: GroupOptions;
|
||||
group: GroupingKey;
|
||||
onOpenJumpToDialog: () => void;
|
||||
itemCount?: number;
|
||||
};
|
||||
|
||||
export const SectionHeader = React.memo<
|
||||
@@ -53,11 +60,13 @@ export const SectionHeader = React.memo<
|
||||
color,
|
||||
screen,
|
||||
groupOptions,
|
||||
onOpenJumpToDialog
|
||||
group,
|
||||
onOpenJumpToDialog,
|
||||
itemCount
|
||||
}: SectionHeaderProps) {
|
||||
const { colors } = useThemeColors();
|
||||
const isCompactModeEnabled = useIsCompactModeEnabled(
|
||||
dataType as "note" | "notebook"
|
||||
dataType as "note" | "notebook" | "searchResult"
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -104,7 +113,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>
|
||||
@@ -133,6 +144,7 @@ export const SectionHeader = React.memo<
|
||||
<Sort
|
||||
screen={screen}
|
||||
type={dataType}
|
||||
group={group}
|
||||
hideGroupOptions={
|
||||
screen === "Reminders" || screen === "Search"
|
||||
}
|
||||
@@ -150,7 +162,8 @@ export const SectionHeader = React.memo<
|
||||
hidden={
|
||||
dataType !== "note" &&
|
||||
dataType !== "notebook" &&
|
||||
screen !== "Notes"
|
||||
screen !== "Notes" &&
|
||||
screen !== "Search"
|
||||
}
|
||||
style={{
|
||||
width: 25,
|
||||
@@ -163,9 +176,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 +206,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}
|
||||
|
||||
@@ -53,6 +53,7 @@ type ListProps = {
|
||||
isRenderedInActionSheet?: boolean;
|
||||
CustomListComponent?: React.JSX.ElementType;
|
||||
placeholder?: PlaceholderData;
|
||||
groupType: GroupingKey;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
@@ -73,16 +74,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);
|
||||
|
||||
const _onRefresh = async () => {
|
||||
Sync.run("global", false, "full", () => {
|
||||
@@ -105,7 +97,7 @@ export default function List(props: ListProps) {
|
||||
isSheet={props.isRenderedInActionSheet || false}
|
||||
items={props.data}
|
||||
groupOptions={groupOptions}
|
||||
group={groupType as GroupingKey}
|
||||
group={props.groupType as GroupingKey}
|
||||
renderedInRoute={props.renderedInRoute}
|
||||
customAccentColor={props.customAccentColor}
|
||||
dataType={props.dataType}
|
||||
@@ -115,7 +107,7 @@ export default function List(props: ListProps) {
|
||||
},
|
||||
[
|
||||
groupOptions,
|
||||
groupType,
|
||||
props.groupType,
|
||||
props.customAccentColor,
|
||||
props.data,
|
||||
props.dataType,
|
||||
|
||||
@@ -53,7 +53,7 @@ import TagItem from "../list-items/tag";
|
||||
import { SearchResult } from "../list-items/search-result";
|
||||
|
||||
type ListItemWrapperProps<TItem = Item> = {
|
||||
group?: GroupingKey;
|
||||
group: GroupingKey;
|
||||
items: VirtualizedGrouping<TItem> | undefined;
|
||||
isSheet: boolean;
|
||||
index: number;
|
||||
@@ -182,6 +182,7 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
dataType={item.type}
|
||||
group={group}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -218,6 +219,7 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
dataType={item.type}
|
||||
group={group}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -245,6 +247,7 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
screen={props.renderedInRoute}
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
@@ -271,6 +274,7 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
screen={props.renderedInRoute}
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
@@ -297,9 +301,11 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
screen={props.renderedInRoute}
|
||||
item={groupHeader}
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
itemCount={items?.placeholders.length}
|
||||
onOpenJumpToDialog={() => {
|
||||
eSendEvent(eOpenJumpToDialog, {
|
||||
ref: props.scrollRef,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -121,6 +121,17 @@ export const RelationsList = ({
|
||||
<List
|
||||
data={items}
|
||||
loading={false}
|
||||
groupType={
|
||||
referenceType === "note"
|
||||
? "notes"
|
||||
: referenceType === "tag"
|
||||
? "tags"
|
||||
: referenceType === "notebook"
|
||||
? "notebooks"
|
||||
: referenceType === "reminder"
|
||||
? "reminders"
|
||||
: "notes"
|
||||
}
|
||||
dataType={referenceType as any}
|
||||
isRenderedInActionSheet={true}
|
||||
/>
|
||||
|
||||
@@ -181,6 +181,7 @@ export default function ReminderNotify({
|
||||
data={references}
|
||||
loading={false}
|
||||
dataType="note"
|
||||
groupType="notes"
|
||||
isRenderedInActionSheet={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -45,25 +45,16 @@ import Paragraph from "../../ui/typography/paragraph";
|
||||
const Sort = ({
|
||||
type,
|
||||
screen,
|
||||
hideGroupOptions
|
||||
hideGroupOptions,
|
||||
group: groupType
|
||||
}: {
|
||||
type: ItemType;
|
||||
screen?: RouteName;
|
||||
group: GroupingKey;
|
||||
hideGroupOptions?: boolean;
|
||||
}) => {
|
||||
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)
|
||||
);
|
||||
@@ -157,10 +148,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 +184,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) {
|
||||
@@ -498,6 +498,11 @@ const TabBar = (props: SimpleTabBarProps) => {
|
||||
? "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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -113,7 +117,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 +141,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 +154,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
|
||||
const handleConfirm = (date: Date) => {
|
||||
timer.current = setTimeout(() => {
|
||||
setDateError(undefined);
|
||||
hideDatePicker();
|
||||
setDate(date);
|
||||
}, 10);
|
||||
@@ -172,26 +184,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,7 +274,10 @@ 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()}
|
||||
@@ -270,12 +286,15 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
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 +480,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 +511,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 +561,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={{
|
||||
|
||||
@@ -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]()}
|
||||
|
||||
@@ -185,6 +185,7 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
|
||||
<List
|
||||
data={notes}
|
||||
dataType="note"
|
||||
groupType="notes"
|
||||
onRefresh={() => {
|
||||
onRequestUpdate();
|
||||
}}
|
||||
|
||||
@@ -193,11 +193,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,6 +217,7 @@ const NotesPage = ({
|
||||
<List
|
||||
data={notes}
|
||||
dataType="note"
|
||||
groupType="notes"
|
||||
onRefresh={onRequestUpdate}
|
||||
loading={false}
|
||||
renderedInRoute={route.name}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -517,6 +529,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 +1074,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 +1095,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();
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = 2184
|
||||
IOS_MARKETING_VERSION = 3.3.24
|
||||
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 = 2184
|
||||
IOS_MARKETING_VERSION = 3.3.24
|
||||
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 = 2184
|
||||
IOS_MARKETING_VERSION = 3.3.24
|
||||
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
|
||||
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
|
||||
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share
|
||||
|
||||
33
apps/mobile/package-lock.json
generated
33
apps/mobile/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.19",
|
||||
"version": "3.3.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.19",
|
||||
"version": "3.3.23",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
@@ -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",
|
||||
@@ -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.24",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
@@ -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.14",
|
||||
"version": "3.3.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/web",
|
||||
"version": "3.3.14",
|
||||
"version": "3.3.21",
|
||||
"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.14",
|
||||
"version": "3.3.21",
|
||||
"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 }
|
||||
@@ -52,6 +54,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 +86,7 @@ export function DayPicker(props: DayPickerProps) {
|
||||
<Button
|
||||
variant="icon"
|
||||
sx={{ p: 0 }}
|
||||
{...subtractOffset({ months: 1 })}
|
||||
{...subtractOffset({ months: 1 }, { disabled: isPrevMonthBeforeMin })}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
@@ -83,9 +103,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 +116,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 +135,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 +148,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 +230,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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,6 +324,7 @@ function GroupHeader(props: GroupHeaderProps) {
|
||||
cursor: "pointer",
|
||||
px: 1,
|
||||
py: 1,
|
||||
pr: 2,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
// border: isMenuTarget ? "1px solid" : "none",
|
||||
// borderColor: isMenuTarget ? "accent" : "transparent",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -55,6 +55,8 @@ export const CustomScrollbarsVirtualList = forwardRef<
|
||||
return (
|
||||
<ScrollContainer
|
||||
{...props}
|
||||
trackStyle={() => ({ width: 6 })}
|
||||
thumbStyle={() => ({ width: 6 })}
|
||||
forwardedRef={(sRef) => {
|
||||
if (typeof ref === "function") ref(sRef);
|
||||
else if (ref) ref.current = sRef;
|
||||
|
||||
@@ -148,6 +148,7 @@ function ListItem<TItem extends Item, TContext>(
|
||||
sx={{
|
||||
px: 1,
|
||||
py: isCompact ? 0 : 1,
|
||||
pr: isCompact ? 10 : 0,
|
||||
height: isCompact ? 25 : "inherit",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
|
||||
@@ -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",
|
||||
@@ -713,7 +714,7 @@ function colorsToMenuItems(
|
||||
const isChecked = !!noteColor && noteColor.id === color.id;
|
||||
return {
|
||||
type: "button",
|
||||
key: color.title,
|
||||
key: color.id,
|
||||
title: color.title,
|
||||
icon: Circle.path,
|
||||
styles: { icon: { color: color.colorCode } },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -68,13 +68,23 @@ export function getWindowControls(
|
||||
];
|
||||
}
|
||||
export const TITLE_BAR_HEIGHT = 37;
|
||||
export function TitleBar({ isUnderlay = isMac() }: { isUnderlay?: boolean }) {
|
||||
export function TitleBar({
|
||||
isUnderlay = isMac(),
|
||||
force = false
|
||||
}: {
|
||||
isUnderlay?: boolean;
|
||||
force?: boolean;
|
||||
}) {
|
||||
const { isFullscreen, hasNativeWindowControls, isMaximized } =
|
||||
useWindowControls();
|
||||
const isTablet = useTablet();
|
||||
const isMobile = useMobile();
|
||||
if ((!isMac() && !isMobile && !isTablet) || (isFullscreen && isMac()))
|
||||
if (
|
||||
!force &&
|
||||
((!isMac() && !isMobile && !isTablet) || (isFullscreen && isMac()))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tools = getWindowControls(
|
||||
hasNativeWindowControls,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -128,7 +128,10 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callback_data.data || SUBSCRIBED_EVENTS.indexOf(event_name) === -1) {
|
||||
if (
|
||||
!callback_data?.data ||
|
||||
SUBSCRIBED_EVENTS.indexOf(event_name) === -1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface Checkout {
|
||||
export type PaddleEvent = {
|
||||
action: "event";
|
||||
event_name: CheckoutEventNames;
|
||||
callback_data: PaddleEventData;
|
||||
callback_data?: PaddleEventData;
|
||||
};
|
||||
|
||||
export type PlanMetadata = Record<
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -22,7 +22,14 @@ import Dialog from "../components/dialog";
|
||||
import { getHomeRoute, hardNavigate } from "../navigation";
|
||||
import { appVersion } from "../utils/version";
|
||||
import Config from "../utils/config";
|
||||
import { ArrowRight, Checkmark, Icon, Warn } from "../components/icons";
|
||||
import {
|
||||
ArrowRight,
|
||||
Checkmark,
|
||||
Icon,
|
||||
Warn,
|
||||
File,
|
||||
InternalLink
|
||||
} from "../components/icons";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
|
||||
@@ -1,147 +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 { useEffect, useState } from "react";
|
||||
import Dialog from "../components/dialog";
|
||||
import { ScrollContainer } from "@notesnook/ui";
|
||||
import { Flex, Image, Label, Text } from "@theme-ui/components";
|
||||
import { formatBytes } from "@notesnook/common";
|
||||
import { compressImage, FileWithURI } from "../utils/image-compressor";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { checkFeature } from "../common";
|
||||
|
||||
export type ImagePickerDialogProps = BaseDialogProps<false | File[]> & {
|
||||
images: File[];
|
||||
};
|
||||
|
||||
export const ImagePickerDialog = DialogManager.register(
|
||||
function ImagePickerDialog(props: ImagePickerDialogProps) {
|
||||
const [images, setImages] = useState<FileWithURI[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [compress, setCompress] = useState(true);
|
||||
const selectedImage = images[selectedIndex];
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
const images: FileWithURI[] = [];
|
||||
for (const image of props.images) {
|
||||
const compressed = compress
|
||||
? await compressImage(image, {
|
||||
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
|
||||
width: (naturalWidth) => naturalWidth,
|
||||
height: (_, naturalHeight) => naturalHeight,
|
||||
resize: "contain",
|
||||
quality: 0.7
|
||||
})
|
||||
: image;
|
||||
images.push(
|
||||
new FileWithURI([compressed], image.name, {
|
||||
lastModified: image.lastModified,
|
||||
type: image.type
|
||||
})
|
||||
);
|
||||
}
|
||||
setImages(images);
|
||||
})();
|
||||
}, [props.images, compress]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
images.forEach((i) => URL.revokeObjectURL(i.uri));
|
||||
};
|
||||
}, [images]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={() => props.onClose(false)}
|
||||
positiveButton={{
|
||||
text: strings.insert(),
|
||||
onClick: () => props.onClose(images)
|
||||
}}
|
||||
negativeButton={{
|
||||
text: strings.cancel(),
|
||||
onClick: () => props.onClose(false)
|
||||
}}
|
||||
>
|
||||
{selectedImage && (
|
||||
<Flex sx={{ flexDirection: "column", alignItems: "center", mt: 4 }}>
|
||||
<Image
|
||||
src={selectedImage.uri}
|
||||
sx={{
|
||||
maxHeight: 250,
|
||||
objectFit: "contain",
|
||||
alignSelf: "center",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
/>
|
||||
<Text variant="subBody" sx={{ my: 2 }}>
|
||||
{selectedImage.name} ({formatBytes(selectedImage.size)})
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{images.length > 1 ? (
|
||||
<ScrollContainer style={{ display: "flex" }}>
|
||||
{images.map((image, index) => (
|
||||
<Image
|
||||
key={image.name + image.size}
|
||||
src={image.uri}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
height: "55px",
|
||||
width: "55px",
|
||||
objectFit: "contain",
|
||||
border:
|
||||
selectedIndex === index
|
||||
? "2px solid var(--accent)"
|
||||
: "2px solid transparent",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</ScrollContainer>
|
||||
) : null}
|
||||
|
||||
<Label variant="text.body" sx={{ mt: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{
|
||||
accentColor: "var(--accent)",
|
||||
marginRight: 1,
|
||||
width: 14,
|
||||
height: 14
|
||||
}}
|
||||
defaultChecked={compress}
|
||||
checked={compress}
|
||||
onChange={async () => {
|
||||
if (!(await checkFeature("fullQualityImages", { type: "toast" })))
|
||||
return;
|
||||
setCompress((s) => !s);
|
||||
}}
|
||||
/>
|
||||
<span style={{ marginLeft: 5 }}>
|
||||
Enable compression (recommended)
|
||||
</span>
|
||||
</Label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -25,7 +25,7 @@ import { writeText } from "clipboard-polyfill";
|
||||
import { store as userstore } from "../stores/user-store";
|
||||
|
||||
import { ErrorText } from "../components/error-text";
|
||||
import { Debug } from "@notesnook/core";
|
||||
import { Debug, IssueReportResponse } from "@notesnook/core";
|
||||
import { ConfirmDialog } from "./confirm";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
@@ -76,15 +76,16 @@ export const IssueDialog = DialogManager.register(function IssueDialog(
|
||||
|
||||
if (!requestData.title.trim() || !requestData.body.trim()) return;
|
||||
requestData.body = BODY_TEMPLATE(requestData.body);
|
||||
const url = await Debug.report({
|
||||
const response = await Debug.report({
|
||||
title: requestData.title,
|
||||
body: requestData.body,
|
||||
userId: userstore.get().user?.id
|
||||
});
|
||||
if (!url) throw new Error("Could not submit bug report.");
|
||||
if (!response) throw new Error("Could not submit bug report.");
|
||||
if ("error" in response) throw new Error(response.error);
|
||||
|
||||
props.onClose(true);
|
||||
await showIssueReportedDialog({ url });
|
||||
await showIssueReportedDialog(response);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) setError(e.message);
|
||||
} finally {
|
||||
@@ -157,14 +158,37 @@ export const IssueDialog = DialogManager.register(function IssueDialog(
|
||||
);
|
||||
});
|
||||
|
||||
function showIssueReportedDialog({ url }: { url: string }) {
|
||||
return ConfirmDialog.show({
|
||||
title: strings.thankYouForReporting(),
|
||||
positiveButtonText: strings.copyLink(),
|
||||
message: strings.bugReportMessage(url)
|
||||
}).then((result) => {
|
||||
result && writeText(url);
|
||||
});
|
||||
function showIssueReportedDialog(response: IssueReportResponse) {
|
||||
if ("error" in response) return;
|
||||
|
||||
switch (response.type) {
|
||||
case "email": {
|
||||
return ConfirmDialog.show({
|
||||
title: strings.yourSupportRequestHasBeenForwarded(),
|
||||
message: strings.supportEmailMessage()
|
||||
});
|
||||
}
|
||||
case "discussion": {
|
||||
const url = response.url;
|
||||
return ConfirmDialog.show({
|
||||
title: strings.thankYouForFeedback(),
|
||||
positiveButtonText: strings.copyLink(),
|
||||
message: strings.featureRequestMessage(url)
|
||||
}).then((result) => {
|
||||
result && writeText(url);
|
||||
});
|
||||
}
|
||||
case "issue": {
|
||||
const url = response.url;
|
||||
return ConfirmDialog.show({
|
||||
title: strings.thankYouForReporting(),
|
||||
positiveButtonText: strings.copyLink(),
|
||||
message: strings.bugReportMessage(url)
|
||||
}).then((result) => {
|
||||
result && writeText(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BODY_TEMPLATE = (body: string) => {
|
||||
|
||||
@@ -240,7 +240,8 @@ export const BehaviourSettings: SettingsGroup[] = [
|
||||
useSettingStore.subscribe((s) => s.autoUpdates, listener),
|
||||
isHidden: () =>
|
||||
useSettingStore.getState().isFlatpak ||
|
||||
useSettingStore.getState().isSnap,
|
||||
useSettingStore.getState().isSnap ||
|
||||
useSettingStore.getState().isPortable,
|
||||
components: [
|
||||
{
|
||||
type: "toggle",
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getFeaturesUsage,
|
||||
usePromise
|
||||
} from "@notesnook/common";
|
||||
import { SubscriptionProvider } from "@notesnook/core";
|
||||
|
||||
export function SubscriptionStatus() {
|
||||
const user = useUserStore((store) => store.user);
|
||||
@@ -44,6 +45,8 @@ export function SubscriptionStatus() {
|
||||
? ""
|
||||
: trial
|
||||
? `Your free trial is on-going. Your subscription will start on ${trialExpiryDate}.`
|
||||
: user?.subscription.provider === SubscriptionProvider.GIFT_CARD
|
||||
? `Your subscription will expire on ${expiryDate}.`
|
||||
: autoRenew
|
||||
? `Your subscription will auto renew on ${expiryDate}.`
|
||||
: expiryDate
|
||||
|
||||
@@ -47,6 +47,7 @@ import { FlexScrollContainer } from "../../components/scroll-container";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
DropdownSettingComponent,
|
||||
Section,
|
||||
SectionGroup,
|
||||
SectionKeys,
|
||||
Setting,
|
||||
@@ -254,9 +255,15 @@ export const SettingsDialog = DialogManager.register(function SettingsDialog(
|
||||
overflow: "auto"
|
||||
}}
|
||||
>
|
||||
{activeSettings.map((group) => (
|
||||
<SettingsGroupComponent item={group} />
|
||||
))}
|
||||
{activeSettings.length > 0 ? (
|
||||
activeSettings.map((group) => (
|
||||
<SettingsGroupComponent item={group} />
|
||||
))
|
||||
) : (
|
||||
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
|
||||
{strings.noResultsFound()}
|
||||
</Text>
|
||||
)}
|
||||
</FlexScrollContainer>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
@@ -317,12 +324,17 @@ function SettingsSideBar(props: SettingsSideBarProps) {
|
||||
SettingsGroups.filter((g) => g.section === route)
|
||||
);
|
||||
|
||||
const groups: SettingsGroup[] = [];
|
||||
let groups: SettingsGroup[] = [];
|
||||
for (const group of SettingsGroups) {
|
||||
const section = findSection(group.section);
|
||||
if (section?.isHidden?.() || group.isHidden?.()) continue;
|
||||
|
||||
const isTitleMatch =
|
||||
typeof group.header === "string" &&
|
||||
group.header.toLowerCase().includes(query);
|
||||
const isSectionMatch = group.section.includes(query);
|
||||
const isSectionMatch = group.section
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
|
||||
if (isTitleMatch || isSectionMatch) {
|
||||
groups.push(group);
|
||||
@@ -347,6 +359,18 @@ function SettingsSideBar(props: SettingsSideBarProps) {
|
||||
if (!settings.length) continue;
|
||||
groups.push({ ...group, settings });
|
||||
}
|
||||
const matchedSections = findSections(query);
|
||||
if (matchedSections.length > 0) {
|
||||
const matchedGroups = SettingsGroups.filter((g) =>
|
||||
matchedSections.some((s) => s.key === g.section)
|
||||
);
|
||||
// remove groups whose sections were matched to avoid duplicate
|
||||
// entries.
|
||||
groups = groups.filter(
|
||||
(g) => !matchedGroups.some((mg) => mg.section === g.section)
|
||||
);
|
||||
groups.push(...matchedGroups);
|
||||
}
|
||||
onNavigate(groups);
|
||||
}}
|
||||
/>
|
||||
@@ -413,7 +437,10 @@ function SettingsGroupComponent(props: { item: SettingsGroup }) {
|
||||
};
|
||||
}, [onStateChange]);
|
||||
|
||||
if (item.isHidden?.()) return null;
|
||||
const allHidden = item.settings.every((s) => s.isHidden?.());
|
||||
const hasComponentHeader = typeof item.header !== "string";
|
||||
if (item.isHidden?.() || (allHidden && !hasComponentHeader)) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
@@ -717,3 +744,22 @@ function NumberInput({
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function findSection(key: SectionKeys) {
|
||||
for (const group of sectionGroups) {
|
||||
const section = group.sections.find((s) => s.key === key);
|
||||
if (section) return section;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSections(query: string) {
|
||||
const sections: Section[] = [];
|
||||
for (const group of sectionGroups) {
|
||||
for (const section of group.sections) {
|
||||
if (section.isHidden?.()) continue;
|
||||
if (section.title.toLowerCase().includes(query)) sections.push(section);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
@@ -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 { SettingsGroup } from "./types";
|
||||
import { SettingComponent, SettingsGroup } from "./types";
|
||||
import { appVersion } from "../../utils/version";
|
||||
import { writeText } from "clipboard-polyfill";
|
||||
import { showToast } from "../../utils/toast";
|
||||
@@ -50,6 +50,23 @@ export const AboutSettings: SettingsGroup[] = [
|
||||
useAutoUpdateStore.subscribe((s) => s.status, listener),
|
||||
components: () => {
|
||||
const status = useAutoUpdateStore.getState().status;
|
||||
const copyVersionButton: SettingComponent = {
|
||||
type: "button",
|
||||
action: async () => {
|
||||
await writeText(appVersion.formatted);
|
||||
showToast("info", strings.copied());
|
||||
},
|
||||
title: strings.copy(),
|
||||
variant: "secondary"
|
||||
};
|
||||
if (
|
||||
useSettingStore.getState().isFlatpak ||
|
||||
useSettingStore.getState().isSnap ||
|
||||
useSettingStore.getState().isPortable
|
||||
) {
|
||||
return [copyVersionButton];
|
||||
}
|
||||
|
||||
return [
|
||||
status?.type === "available"
|
||||
? {
|
||||
@@ -64,15 +81,7 @@ export const AboutSettings: SettingsGroup[] = [
|
||||
title: strings.checkForUpdates(),
|
||||
variant: "secondary"
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action: async () => {
|
||||
await writeText(appVersion.formatted);
|
||||
showToast("info", strings.copied());
|
||||
},
|
||||
title: strings.copy(),
|
||||
variant: "secondary"
|
||||
}
|
||||
copyVersionButton
|
||||
];
|
||||
}
|
||||
},
|
||||
@@ -82,7 +91,8 @@ export const AboutSettings: SettingsGroup[] = [
|
||||
description: strings.releaseTrackDesc(),
|
||||
isHidden: () =>
|
||||
useSettingStore.getState().isFlatpak ||
|
||||
useSettingStore.getState().isSnap,
|
||||
useSettingStore.getState().isSnap ||
|
||||
useSettingStore.getState().isPortable,
|
||||
components: [
|
||||
{
|
||||
type: "dropdown",
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "@notesnook/core";
|
||||
import { logger } from "../utils/logger";
|
||||
import { newQueue } from "@henrygd/queue";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
export const ABYTES = 17;
|
||||
const CHUNK_SIZE = 512 * 1024;
|
||||
@@ -499,12 +500,20 @@ async function downloadFile(
|
||||
{ type: "download", hash: filename }
|
||||
);
|
||||
|
||||
const signedUrl = (
|
||||
await axios.get(url, {
|
||||
headers,
|
||||
responseType: "text"
|
||||
})
|
||||
).data;
|
||||
const signedUrlResponse = await axios
|
||||
.get(url, { headers, responseType: "text" })
|
||||
.catch((e) => {
|
||||
if (e.response?.status === 401) {
|
||||
showToast("error", strings.pleaseLoginToDownloadAttachments());
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
if (!signedUrlResponse) {
|
||||
reportProgress(undefined, { type: "download", hash: filename });
|
||||
return false;
|
||||
}
|
||||
const signedUrl = signedUrlResponse.data;
|
||||
|
||||
logger.debug("Got attachment signed url", { filename });
|
||||
|
||||
|
||||
57
apps/web/src/navigation/auth-routes.ts
Normal file
57
apps/web/src/navigation/auth-routes.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
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 { getCurrentPath } from "./index";
|
||||
|
||||
export type AuthRoutes =
|
||||
| "sessionExpiry"
|
||||
| "login:email"
|
||||
| "login:password"
|
||||
| "signup"
|
||||
| "recover"
|
||||
| "mfa:code"
|
||||
| "mfa:select";
|
||||
|
||||
export const authRoutes: Record<AuthRoutes, string> = {
|
||||
"login:email": "/login",
|
||||
"login:password": "/login/password",
|
||||
"mfa:code": "/login/mfa/code",
|
||||
"mfa:select": "/login/mfa/select",
|
||||
recover: "/recover",
|
||||
sessionExpiry: "/sessionexpired",
|
||||
signup: "/signup"
|
||||
};
|
||||
|
||||
const unauthorizedRoutes: AuthRoutes[] = [
|
||||
"login:email",
|
||||
"login:password",
|
||||
"signup",
|
||||
"mfa:code",
|
||||
"mfa:select",
|
||||
"recover"
|
||||
];
|
||||
|
||||
export function isAuthRoute() {
|
||||
const path = getCurrentPath();
|
||||
return Object.values(authRoutes).includes(path);
|
||||
}
|
||||
|
||||
export function isUnauthorizedRoute(route: AuthRoutes) {
|
||||
return unauthorizedRoutes.includes(route);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user