mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07db14f6bf | ||
|
|
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 | ||
|
|
e023f11cd9 |
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
|
||||
|
||||
4
apps/desktop/package-lock.json
generated
4
apps/desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.17",
|
||||
"version": "3.3.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "3.3.17",
|
||||
"version": "3.3.20",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@notesnook/desktop",
|
||||
"productName": "Notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "3.3.17",
|
||||
"version": "3.3.20",
|
||||
"appAppleId": "1544027013",
|
||||
"private": true,
|
||||
"main": "./dist/cjs/index.js",
|
||||
|
||||
@@ -33,16 +33,17 @@ import { AutoLaunch } from "../utils/autolaunch";
|
||||
import { config, DesktopIntegration } from "../utils/config";
|
||||
import { bringToFront } from "../utils/bring-to-front";
|
||||
import { getTheme, setTheme, Theme } from "../utils/theme";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import { resolvePath } from "../utils/resolve-path";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { AssetManager } from "../utils/asset-manager";
|
||||
import { isFlatpak, isSnap } from "../utils";
|
||||
import { isFlatpak, isPortable, isSnap } from "../utils";
|
||||
import { setupDesktopIntegration } from "../utils/desktop-integration";
|
||||
import { rm } from "fs/promises";
|
||||
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
|
||||
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
|
||||
import { platform } from "os";
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
@@ -60,6 +61,7 @@ const NotificationOptions = z.object({
|
||||
export const osIntegrationRouter = t.router({
|
||||
isFlatpak: t.procedure.query(() => isFlatpak()),
|
||||
isSnap: t.procedure.query(() => isSnap()),
|
||||
isPortable: t.procedure.query(() => isPortable()),
|
||||
|
||||
zoomFactor: t.procedure.query(() => config.zoomFactor),
|
||||
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
|
||||
@@ -191,9 +193,28 @@ export const osIntegrationRouter = t.router({
|
||||
}),
|
||||
openPath: t.procedure
|
||||
.input(z.object({ type: z.literal("path"), link: z.string() }))
|
||||
.query(({ input }) => {
|
||||
.query(async ({ input }) => {
|
||||
if (isFlatpak()) return;
|
||||
|
||||
const { type, link } = input;
|
||||
if (type === "path") return shell.openPath(resolvePath(link));
|
||||
if (type !== "path") return;
|
||||
|
||||
const resolvedPath = resolvePath(
|
||||
// remove leading slash from path on windows
|
||||
platform() === "win32" ? link.slice(1) : link
|
||||
);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
if (globalThis.window) {
|
||||
await dialog.showMessageBox(globalThis.window, {
|
||||
type: "error",
|
||||
title: "Path not found",
|
||||
message: `The path does not exist:\n${wrapPath(resolvedPath)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await shell.openPath(resolvedPath);
|
||||
}),
|
||||
bringToFront: t.procedure.query(() => bringToFront()),
|
||||
changeTheme: t.procedure
|
||||
@@ -298,3 +319,7 @@ function toMenuItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapPath(path: string, maxLineLength = 100): string {
|
||||
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
|
||||
}
|
||||
|
||||
@@ -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 3103
|
||||
versionCode 3105
|
||||
}
|
||||
versionName getNpmVersion()
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
|
||||
@@ -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,24 +154,48 @@ 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"
|
||||
},
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -67,15 +67,13 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
// Handle form submission if form is available
|
||||
if (dialogInfo?.form && formRef.current) {
|
||||
inputRef.current?.blur();
|
||||
setLoading(true);
|
||||
try {
|
||||
const isValid = await formRef.current.validate();
|
||||
if (!isValid) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogInfo.form.onFormSubmit) {
|
||||
setLoading(true);
|
||||
const result = await dialogInfo.form.onFormSubmit(formRef.current);
|
||||
if (result === false) {
|
||||
setLoading(false);
|
||||
@@ -93,7 +91,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
let result = false;
|
||||
try {
|
||||
result = await dialogInfo.positivePress(
|
||||
values.current.inputValue || dialogInfo.defaultValue,
|
||||
values.current.inputValue,
|
||||
checked
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ColorValue,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
@@ -220,6 +221,7 @@ interface FormInputProps extends TextInputProps {
|
||||
inputStyle?: TextInputProps["style"];
|
||||
containerStyle?: ViewStyle;
|
||||
wrapperStyle?: ViewStyle;
|
||||
errorStyle?: TextStyle;
|
||||
}
|
||||
|
||||
export function FormInput({
|
||||
@@ -245,6 +247,7 @@ export function FormInput({
|
||||
onBlur,
|
||||
onPress,
|
||||
onChangeText,
|
||||
errorStyle,
|
||||
...restProps
|
||||
}: FormInputProps) {
|
||||
const { colors, isDark } = useThemeColors();
|
||||
@@ -391,7 +394,7 @@ export function FormInput({
|
||||
{fieldError ? (
|
||||
<Paragraph
|
||||
size={AppFontSize.xs}
|
||||
style={{ marginTop: 5, color: colors.error.icon }}
|
||||
style={[{ marginTop: 5, color: colors.error.icon }, errorStyle]}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
|
||||
@@ -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
|
||||
@@ -1121,8 +1128,9 @@ export const useActions = ({
|
||||
id: "add-reminder",
|
||||
title: strings.remindMe(),
|
||||
icon: "clock-plus-outline",
|
||||
onPress: () => {
|
||||
onPress: async () => {
|
||||
close();
|
||||
await sleep(100);
|
||||
AddReminder.present(undefined, item);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -35,7 +35,6 @@ import { db } from "../../common/database";
|
||||
import { Dialog } from "../../components/dialog";
|
||||
import { Header } from "../../components/header";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import Input from "../../components/ui/input";
|
||||
import { ReminderTime } from "../../components/ui/reminder-time";
|
||||
import Paragraph from "../../components/ui/typography/paragraph";
|
||||
import { DDS } from "../../services/device-detection";
|
||||
@@ -59,6 +58,11 @@ import { TimeSince } from "../../components/ui/time-since";
|
||||
import Heading from "../../components/ui/typography/heading";
|
||||
import { eOnLoadNote } from "../../utils/events";
|
||||
import { fluidTabsRef } from "../../utils/global-refs";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../components/ui/input/form-input";
|
||||
import AppIcon from "../../components/ui/AppIcon";
|
||||
|
||||
const ReminderModes =
|
||||
Platform.OS === "ios"
|
||||
@@ -91,7 +95,17 @@ const ReminderNotificationModes = {
|
||||
|
||||
export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
const { reminder, reference } = props.route.params;
|
||||
useNavigationFocus(props.navigation, { focusOnInit: true });
|
||||
useNavigationFocus(props.navigation, {
|
||||
focusOnInit: true,
|
||||
onFocus: () => {
|
||||
if (!props.route.params?.reminder) {
|
||||
setTimeout(() => {
|
||||
titleRef.current?.focus();
|
||||
}, 200);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const weekFormat = useSettingStore((state) => state.weekFormat);
|
||||
const [reminderMode, setReminderMode] = useState<Reminder["mode"]>(
|
||||
@@ -113,7 +127,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
const [repeatFrequency, setRepeatFrequency] = useState(1);
|
||||
const referencedItem = reference ? (reference as Note) : null;
|
||||
const recurringReminderFeature = useIsFeatureAvailable("recurringReminders");
|
||||
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
title: reminder?.title || referencedItem?.title || "",
|
||||
description: reminder?.description || referencedItem?.headline || ""
|
||||
})
|
||||
);
|
||||
const title = useRef<string | undefined>(
|
||||
!reminder ? referencedItem?.title : reminder?.title
|
||||
);
|
||||
@@ -132,6 +151,8 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
: null,
|
||||
[reminder?.id]
|
||||
);
|
||||
const [dateError, setDateError] = useState<string>();
|
||||
const [selectDayError, setSelectDayError] = useState<string>();
|
||||
|
||||
const showDatePicker = () => {
|
||||
setDatePickerVisibility(true);
|
||||
@@ -143,6 +164,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
|
||||
const handleConfirm = (date: Date) => {
|
||||
timer.current = setTimeout(() => {
|
||||
setDateError(undefined);
|
||||
hideDatePicker();
|
||||
setDate(date);
|
||||
}, 10);
|
||||
@@ -172,26 +194,27 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
|
||||
async function saveReminder() {
|
||||
try {
|
||||
if (!(await Notifications.checkAndRequestPermissions(true)))
|
||||
throw new Error(strings.noNotificationPermission());
|
||||
if (!date && reminderMode !== ReminderModes.Permanent) return;
|
||||
if (!formRef.current.validate()) return;
|
||||
if (date.getTime() < Date.now() && reminderMode === "once") {
|
||||
setDateError(strings.dateError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
reminderMode === ReminderModes.Repeat &&
|
||||
recurringMode !== "day" &&
|
||||
recurringMode !== "year" &&
|
||||
selectedDays.length === 0
|
||||
)
|
||||
throw new Error(strings.selectDayError());
|
||||
|
||||
if (!title.current) throw new Error(strings.setTitleError());
|
||||
if (
|
||||
date.getTime() < Date.now() &&
|
||||
reminderMode === "once" &&
|
||||
!props.route.params.reminder
|
||||
) {
|
||||
throw new Error(strings.dateError());
|
||||
setSelectDayError(strings.selectDayError());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!date && reminderMode !== ReminderModes.Permanent) return;
|
||||
|
||||
if (!(await Notifications.checkAndRequestPermissions(true)))
|
||||
throw new Error(strings.noNotificationPermission());
|
||||
|
||||
date.setSeconds(0, 0);
|
||||
|
||||
const reminderId = await db.reminders?.add({
|
||||
@@ -261,21 +284,26 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Input
|
||||
<FormInput
|
||||
name="title"
|
||||
validators={[validators.required(strings.titleIsRequired())]}
|
||||
formRef={formRef}
|
||||
fwdRef={titleRef}
|
||||
defaultValue={reminder?.title || referencedItem?.title}
|
||||
placeholder={strings.remindeMeOf()}
|
||||
onChangeText={(text) => (title.current = text)}
|
||||
autoFocus
|
||||
wrapperStyle={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
onSubmit={() => {
|
||||
onSubmitEditing={() => {
|
||||
descriptionRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="description"
|
||||
validators={[]}
|
||||
formRef={formRef}
|
||||
defaultValue={
|
||||
reminder ? reminder?.description : referencedItem?.headline
|
||||
}
|
||||
@@ -461,6 +489,22 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
{selectDayError ? (
|
||||
<Paragraph
|
||||
size={AppFontSize.xs}
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
color: colors.error.icon
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{selectDayError}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -476,16 +520,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
<DateTimePickerModal
|
||||
isVisible={isDatePickerVisible}
|
||||
mode="datetime"
|
||||
minimumDate={
|
||||
reminderMode === "once" ? dayjs().toDate() : new Date(0)
|
||||
}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={hideDatePicker}
|
||||
isDarkModeEnabled={isDark}
|
||||
firstDayOfWeek={weekFormat === "Mon" ? 1 : 0}
|
||||
is24Hour={db.settings.getTimeFormat() === "24-hour"}
|
||||
date={date || new Date(Date.now())}
|
||||
themeVariant={isDark ? "dark" : "light"}
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
date={date}
|
||||
minimumDate={
|
||||
reminderMode === "once" ? dayjs().toDate() : new Date(0)
|
||||
}
|
||||
maximumDate={dayjs(date).add(3, "months").toDate()}
|
||||
onDateChange={handleConfirm}
|
||||
theme={isDark ? "dark" : "light"}
|
||||
@@ -519,6 +570,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dateError ? (
|
||||
<Paragraph
|
||||
size={AppFontSize.xs}
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
color: colors.error.icon
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{dateError}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -529,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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -26,7 +26,6 @@ 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();
|
||||
@@ -89,7 +88,6 @@ function migrateAppLock() {
|
||||
biometricsAuthEnabled: true
|
||||
});
|
||||
}
|
||||
DatabaseLogger.debug("App lock Migrated");
|
||||
}
|
||||
|
||||
function migrateSettings(settings: SettingStore["settings"]) {
|
||||
|
||||
@@ -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 = 2182
|
||||
IOS_MARKETING_VERSION = 3.3.22
|
||||
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 = 2182
|
||||
IOS_MARKETING_VERSION = 3.3.22
|
||||
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 = 2182
|
||||
IOS_MARKETING_VERSION = 3.3.22
|
||||
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
|
||||
|
||||
4
apps/mobile/package-lock.json
generated
4
apps/mobile/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.22",
|
||||
"version": "3.3.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.22",
|
||||
"version": "3.3.23",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.3.22",
|
||||
"version": "3.3.24",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
|
||||
4
apps/web/package-lock.json
generated
4
apps/web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/web",
|
||||
"version": "3.3.17",
|
||||
"version": "3.3.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/web",
|
||||
"version": "3.3.17",
|
||||
"version": "3.3.20",
|
||||
"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.17",
|
||||
"version": "3.3.20",
|
||||
"private": true,
|
||||
"main": "./src/app.js",
|
||||
"homepage": "https://notesnook.com/",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -89,7 +96,19 @@ const features: Record<FeatureKeys, Feature> = {
|
||||
)
|
||||
}
|
||||
]
|
||||
: [],
|
||||
: [
|
||||
{
|
||||
icon: File,
|
||||
title: "Improved attachments UX",
|
||||
subtitle:
|
||||
"We've improved the UI/UX of attaching multiple files into the editor. The entire process is now handled in a unified dialog."
|
||||
},
|
||||
{
|
||||
icon: InternalLink,
|
||||
title: "Opening file links on desktop",
|
||||
subtitle: "The NN Desktop app can now open file links (file:///)."
|
||||
}
|
||||
],
|
||||
cta: {
|
||||
title: strings.gotIt(),
|
||||
icon: Checkmark,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ class SettingStore extends BaseStore<SettingStore> {
|
||||
autoUpdates = false;
|
||||
isFlatpak = false;
|
||||
isSnap = false;
|
||||
isPortable = false;
|
||||
proxyRules?: string;
|
||||
isInboxEnabled = false;
|
||||
|
||||
@@ -104,6 +105,7 @@ class SettingStore extends BaseStore<SettingStore> {
|
||||
profile: db.settings.getProfile(),
|
||||
isFlatpak: await desktop?.integration.isFlatpak.query(),
|
||||
isSnap: await desktop?.integration.isSnap.query(),
|
||||
isPortable: await desktop?.integration.isPortable.query(),
|
||||
desktopIntegrationSettings:
|
||||
await desktop?.integration.desktopIntegration.query(),
|
||||
privacyMode: await desktop?.integration.privacyMode.query(),
|
||||
|
||||
@@ -18,28 +18,37 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { PAGE_VISIBILITY_CHANGE } from "./page-visibility";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { TaskManager } from "../common/task-manager";
|
||||
|
||||
type FilePickerOptions = { acceptedFileTypes: string; multiple?: boolean };
|
||||
|
||||
export function showFilePicker({
|
||||
export async function showFilePicker({
|
||||
acceptedFileTypes,
|
||||
multiple
|
||||
}: FilePickerOptions): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
PAGE_VISIBILITY_CHANGE.ignore = true;
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("multiple", `${multiple || false}`);
|
||||
input.setAttribute("accept", acceptedFileTypes);
|
||||
input.dispatchEvent(new MouseEvent("click"));
|
||||
input.oncancel = async function () {
|
||||
resolve([]);
|
||||
};
|
||||
input.onchange = async function () {
|
||||
if (!input.files) return resolve([]);
|
||||
resolve(Array.from(input.files));
|
||||
};
|
||||
PAGE_VISIBILITY_CHANGE.ignore = true;
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("multiple", `${multiple || false}`);
|
||||
input.setAttribute("accept", acceptedFileTypes);
|
||||
input.dispatchEvent(new MouseEvent("click"));
|
||||
const result = await TaskManager.startTask<File[]>({
|
||||
type: "modal",
|
||||
title: strings.processing(),
|
||||
subtitle: strings.pleaseWait(),
|
||||
action: () =>
|
||||
new Promise((resolve) => {
|
||||
input.oncancel = async function () {
|
||||
resolve([]);
|
||||
};
|
||||
input.onchange = async function () {
|
||||
if (!input.files) return resolve([]);
|
||||
resolve(Array.from(input.files));
|
||||
};
|
||||
})
|
||||
});
|
||||
return result instanceof Error ? [] : result;
|
||||
}
|
||||
|
||||
export async function readFile(file: File): Promise<string> {
|
||||
|
||||
@@ -88,7 +88,11 @@ export class WebExtensionServer implements Server {
|
||||
}
|
||||
);
|
||||
|
||||
const attachment = (await attachFiles([clippedFile]))?.at(0);
|
||||
let attachment;
|
||||
for await (const message of attachFiles([clippedFile], [false])) {
|
||||
if (message.type === "done") attachment = message.attachment;
|
||||
else if (message.type === "error") return;
|
||||
}
|
||||
if (!attachment) return;
|
||||
|
||||
clipContent += h("iframe", [], {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Login to restore attachments in backup.
|
||||
description: We require users to be logged in to restore attachments in backup.
|
||||
---
|
||||
|
||||
# Login to restore attachments in backup.
|
||||
|
||||
We require users to be logged in to restore attachments in backup. This is because attachments are encrypted using a sub-key derived from your database encryption key. Without a login, we cannot encrypt/upload/sync attachments.
|
||||
@@ -87,3 +87,4 @@ navigation:
|
||||
- path: faqs/what-are-merge-conflicts.md
|
||||
- path: faqs/is-there-an-eta.md
|
||||
- path: faqs/login-to-upload-attachments.md
|
||||
- path: faqs/login-to-restore-attachments-in-backup.md
|
||||
|
||||
3
fastlane/metadata/android/en-US/changelogs/15519.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/15519.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
- Bug fixes and improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
3
fastlane/metadata/android/en-US/changelogs/15524.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/15524.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
- Bug fixes and improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
3
fastlane/metadata/android/en-US/changelogs/15529.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/15529.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
- Bug fixes and improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
@@ -400,6 +400,16 @@ export class Sync {
|
||||
}
|
||||
|
||||
const collectionType = SYNC_COLLECTIONS_MAP[itemType];
|
||||
|
||||
if (!collectionType) {
|
||||
this.logger.error(
|
||||
new Error(
|
||||
`Unknown collection type for item type ${itemType}. Skipping chunk.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = this.db[collectionType].collection;
|
||||
const localItems = await collection.records(chunk.items.map((i) => i.id));
|
||||
let items: (MaybeDeletedItem<Item> | undefined)[] = [];
|
||||
|
||||
@@ -548,18 +548,24 @@ class UserManager {
|
||||
|
||||
const email = newEmail.toLowerCase();
|
||||
|
||||
await http.patch(
|
||||
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
|
||||
{
|
||||
type: "change_email",
|
||||
new_email: newEmail,
|
||||
password: await this.db.storage().hash(password, email, {
|
||||
usesFallback: await this.usesFallbackPWHash(password)
|
||||
}),
|
||||
verification_code: code
|
||||
},
|
||||
token
|
||||
);
|
||||
try {
|
||||
await http.patch(
|
||||
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
|
||||
{
|
||||
type: "change_email",
|
||||
new_email: newEmail,
|
||||
password: await this.db.storage().hash(password, email, {
|
||||
usesFallback: await this.usesFallbackPWHash(password)
|
||||
}),
|
||||
verification_code: code
|
||||
},
|
||||
token
|
||||
);
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
if (error.message === "Invalid token.") throw new Error("Invalid code.");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
recoverAccount(email: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
|
||||
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
|
||||
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
@@ -947,6 +948,10 @@ msgstr "Are you sure you want to logout and clear all data stored on THIS DEVICE
|
||||
msgid "Are you sure you want to logout from this device? Any unsynced changes will be lost."
|
||||
msgstr "Are you sure you want to logout from this device? Any unsynced changes will be lost."
|
||||
|
||||
#: src/strings.ts:2687
|
||||
msgid "Are you sure you want to open this file: {filePath}?"
|
||||
msgstr "Are you sure you want to open this file: {filePath}?"
|
||||
|
||||
#: src/strings.ts:1046
|
||||
msgid "Are you sure you want to remove your name?"
|
||||
msgstr "Are you sure you want to remove your name?"
|
||||
@@ -983,6 +988,10 @@ msgstr "Attach image from URL"
|
||||
msgid "Attached files"
|
||||
msgstr "Attached files"
|
||||
|
||||
#: src/strings.ts:2678
|
||||
msgid "Attaching files"
|
||||
msgstr "Attaching files"
|
||||
|
||||
#: src/strings.ts:290
|
||||
msgid "attachment"
|
||||
msgstr "attachment"
|
||||
@@ -992,7 +1001,7 @@ msgstr "attachment"
|
||||
msgid "Attachment"
|
||||
msgstr "Attachment"
|
||||
|
||||
#: src/strings.ts:2663
|
||||
#: src/strings.ts:2667
|
||||
msgid "Attachment deleted"
|
||||
msgstr "Attachment deleted"
|
||||
|
||||
@@ -1645,6 +1654,10 @@ msgstr "Click to update"
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: src/strings.ts:2679
|
||||
msgid "Close ({seconds})"
|
||||
msgstr "Close ({seconds})"
|
||||
|
||||
#: src/strings.ts:2019
|
||||
msgid "Close all"
|
||||
msgstr "Close all"
|
||||
@@ -1754,6 +1767,14 @@ msgstr "Compress images before uploading"
|
||||
msgid "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
|
||||
msgstr "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
|
||||
|
||||
#: src/strings.ts:2680
|
||||
msgid "Compressing"
|
||||
msgstr "Compressing"
|
||||
|
||||
#: src/strings.ts:2684
|
||||
msgid "Compression failed"
|
||||
msgstr "Compression failed"
|
||||
|
||||
#: src/strings.ts:2262
|
||||
msgid "Configure"
|
||||
msgstr "Configure"
|
||||
@@ -1968,10 +1989,15 @@ msgstr "Curate the toolbar that fits your needs and matches your personality."
|
||||
msgid "Current note"
|
||||
msgstr "Current note"
|
||||
|
||||
#: src/strings.ts:1479
|
||||
#: src/strings.ts:1485
|
||||
msgid "Current password"
|
||||
msgstr "Current password"
|
||||
|
||||
#: src/strings.ts:2670
|
||||
msgid "Current password required"
|
||||
msgstr "Current password required"
|
||||
|
||||
#: src/strings.ts:1229
|
||||
msgid "Current path: {path}"
|
||||
msgstr "Current path: {path}"
|
||||
@@ -2541,6 +2567,10 @@ msgstr "Encrypted backup"
|
||||
msgid "Encrypted, private, secure."
|
||||
msgstr "Encrypted, private, secure."
|
||||
|
||||
#: src/strings.ts:2681
|
||||
msgid "Encrypting"
|
||||
msgstr "Encrypting"
|
||||
|
||||
#: src/strings.ts:940
|
||||
msgid "Encrypting attachment"
|
||||
msgstr "Encrypting attachment"
|
||||
@@ -2907,10 +2937,18 @@ msgstr "File length is 0. Please upload this file again from the attachment mana
|
||||
msgid "File length mismatch. Expected {expectedSize} but got {currentSize} bytes. Please upload this file again from the attachment manager."
|
||||
msgstr "File length mismatch. Expected {expectedSize} but got {currentSize} bytes. Please upload this file again from the attachment manager."
|
||||
|
||||
#: src/strings.ts:2689
|
||||
msgid "File links cannot be opened in browsers. Please use the Notesnook desktop app."
|
||||
msgstr "File links cannot be opened in browsers. Please use the Notesnook desktop app."
|
||||
|
||||
#: src/strings.ts:949
|
||||
msgid "File mismatch"
|
||||
msgstr "File mismatch"
|
||||
|
||||
#: src/strings.ts:2683
|
||||
msgid "File size limit exceeded. Please upgrade your plan."
|
||||
msgstr "File size limit exceeded. Please upgrade your plan."
|
||||
|
||||
#: src/strings.ts:945
|
||||
msgid "File size should be less than {sizeInMB}"
|
||||
msgstr "File size should be less than {sizeInMB}"
|
||||
@@ -3784,6 +3822,10 @@ msgstr "Login failed"
|
||||
msgid "Login required"
|
||||
msgstr "Login required"
|
||||
|
||||
#: src/strings.ts:2671
|
||||
msgid "Login required to restore attachments"
|
||||
msgstr "Login required to restore attachments"
|
||||
|
||||
#: src/strings.ts:779
|
||||
msgid "Login successful"
|
||||
msgstr "Login successful"
|
||||
@@ -4034,6 +4076,10 @@ msgstr "Multi-layer encryption to most important notes"
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/strings.ts:2669
|
||||
msgid "Name is required."
|
||||
msgstr "Name is required."
|
||||
|
||||
#: src/strings.ts:1688
|
||||
msgid "Native high-performance encryption"
|
||||
msgstr "Native high-performance encryption"
|
||||
@@ -4403,10 +4449,6 @@ msgstr "Okay"
|
||||
msgid "Old - new"
|
||||
msgstr "Old - new"
|
||||
|
||||
#: src/strings.ts:1479
|
||||
msgid "Old password"
|
||||
msgstr "Old password"
|
||||
|
||||
#: src/strings.ts:1835
|
||||
msgid "Older version"
|
||||
msgstr "Older version"
|
||||
@@ -4483,6 +4525,10 @@ msgstr "Open source."
|
||||
msgid "Open the two-factor authentication (TOTP) app to view your authentication code."
|
||||
msgstr "Open the two-factor authentication (TOTP) app to view your authentication code."
|
||||
|
||||
#: src/strings.ts:2685
|
||||
msgid "Opening local file"
|
||||
msgstr "Opening local file"
|
||||
|
||||
#: src/strings.ts:1857
|
||||
msgid "Optional"
|
||||
msgstr "Optional"
|
||||
@@ -4731,6 +4777,10 @@ msgstr "Please fill all the fields to continue."
|
||||
msgid "Please grant notifications permission to add new reminders."
|
||||
msgstr "Please grant notifications permission to add new reminders."
|
||||
|
||||
#: src/strings.ts:2677
|
||||
msgid "Please login to download attachments."
|
||||
msgstr "Please login to download attachments."
|
||||
|
||||
#: src/strings.ts:873
|
||||
msgid "Please make sure you have saved the recovery key. Tap one more time to confirm."
|
||||
msgstr "Please make sure you have saved the recovery key. Tap one more time to confirm."
|
||||
@@ -6664,6 +6714,10 @@ msgstr "Title"
|
||||
msgid "Title format"
|
||||
msgstr "Title format"
|
||||
|
||||
#: src/strings.ts:2668
|
||||
msgid "Title is required"
|
||||
msgstr "Title is required"
|
||||
|
||||
#: src/strings.ts:1188
|
||||
msgid "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
|
||||
msgstr "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
|
||||
@@ -7481,6 +7535,16 @@ msgstr "You have unsynced notes. Take a backup or sync your notes to avoid losin
|
||||
msgid "You must log out in order to change/reset server URLs."
|
||||
msgstr "You must log out in order to change/reset server URLs."
|
||||
|
||||
#: src/strings.ts:2673
|
||||
msgid ""
|
||||
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
|
||||
" \n"
|
||||
"Continue without attachments?"
|
||||
msgstr ""
|
||||
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
|
||||
" \n"
|
||||
"Continue without attachments?"
|
||||
|
||||
#: src/strings.ts:836
|
||||
msgid "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"
|
||||
msgstr "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
|
||||
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
|
||||
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
@@ -947,6 +948,10 @@ msgstr ""
|
||||
msgid "Are you sure you want to logout from this device? Any unsynced changes will be lost."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2687
|
||||
msgid "Are you sure you want to open this file: {filePath}?"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1046
|
||||
msgid "Are you sure you want to remove your name?"
|
||||
msgstr ""
|
||||
@@ -983,6 +988,10 @@ msgstr ""
|
||||
msgid "Attached files"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2678
|
||||
msgid "Attaching files"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:290
|
||||
msgid "attachment"
|
||||
msgstr ""
|
||||
@@ -992,7 +1001,7 @@ msgstr ""
|
||||
msgid "Attachment"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2663
|
||||
#: src/strings.ts:2667
|
||||
msgid "Attachment deleted"
|
||||
msgstr ""
|
||||
|
||||
@@ -1634,6 +1643,10 @@ msgstr ""
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2679
|
||||
msgid "Close ({seconds})"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2019
|
||||
msgid "Close all"
|
||||
msgstr ""
|
||||
@@ -1741,6 +1754,14 @@ msgstr ""
|
||||
|
||||
#: src/strings.ts:144
|
||||
msgid "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
|
||||
msgstr "<<<<<<< HEAD"
|
||||
|
||||
#: src/strings.ts:2680
|
||||
msgid "Compressing"
|
||||
msgstr "<<<<<<< HEAD"
|
||||
|
||||
#: src/strings.ts:2684
|
||||
msgid "Compression failed"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2262
|
||||
@@ -1957,10 +1978,15 @@ msgstr ""
|
||||
msgid "Current note"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1479
|
||||
#: src/strings.ts:1485
|
||||
msgid "Current password"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2670
|
||||
msgid "Current password required"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1229
|
||||
msgid "Current path: {path}"
|
||||
msgstr ""
|
||||
@@ -2530,6 +2556,10 @@ msgstr ""
|
||||
msgid "Encrypted, private, secure."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2681
|
||||
msgid "Encrypting"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:940
|
||||
msgid "Encrypting attachment"
|
||||
msgstr ""
|
||||
@@ -2896,10 +2926,18 @@ msgstr ""
|
||||
msgid "File length mismatch. Expected {expectedSize} but got {currentSize} bytes. Please upload this file again from the attachment manager."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2689
|
||||
msgid "File links cannot be opened in browsers. Please use the Notesnook desktop app."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:949
|
||||
msgid "File mismatch"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2683
|
||||
msgid "File size limit exceeded. Please upgrade your plan."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:945
|
||||
msgid "File size should be less than {sizeInMB}"
|
||||
msgstr ""
|
||||
@@ -3764,6 +3802,10 @@ msgstr ""
|
||||
msgid "Login required"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2671
|
||||
msgid "Login required to restore attachments"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:779
|
||||
msgid "Login successful"
|
||||
msgstr ""
|
||||
@@ -4014,6 +4056,10 @@ msgstr ""
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2669
|
||||
msgid "Name is required."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1688
|
||||
msgid "Native high-performance encryption"
|
||||
msgstr ""
|
||||
@@ -4377,10 +4423,6 @@ msgstr ""
|
||||
msgid "Old - new"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1479
|
||||
msgid "Old password"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1835
|
||||
msgid "Older version"
|
||||
msgstr ""
|
||||
@@ -4457,6 +4499,10 @@ msgstr ""
|
||||
msgid "Open the two-factor authentication (TOTP) app to view your authentication code."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2685
|
||||
msgid "Opening local file"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1857
|
||||
msgid "Optional"
|
||||
msgstr ""
|
||||
@@ -4705,6 +4751,10 @@ msgstr ""
|
||||
msgid "Please grant notifications permission to add new reminders."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2677
|
||||
msgid "Please login to download attachments."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:873
|
||||
msgid "Please make sure you have saved the recovery key. Tap one more time to confirm."
|
||||
msgstr ""
|
||||
@@ -6623,6 +6673,10 @@ msgstr ""
|
||||
msgid "Title format"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2668
|
||||
msgid "Title is required"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:1188
|
||||
msgid "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
|
||||
msgstr ""
|
||||
@@ -7423,6 +7477,13 @@ msgstr ""
|
||||
msgid "You must log out in order to change/reset server URLs."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2673
|
||||
msgid ""
|
||||
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
|
||||
" \n"
|
||||
"Continue without attachments?"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:836
|
||||
msgid "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"
|
||||
msgstr ""
|
||||
|
||||
@@ -1476,7 +1476,7 @@ $day$: Current day (eg. Monday)`,
|
||||
someNotesPublished: () => t`Some notes are published`,
|
||||
unpublishToDelete: () => t`Unpublish notes to delete them`,
|
||||
filterAttachments: () => t`Filter attachments by filename, type or hash`,
|
||||
oldPassword: () => t`Old password`,
|
||||
oldPassword: () => t`Current password`,
|
||||
newPassword: () => t`New password`,
|
||||
email: () => t`Email`,
|
||||
emailInvalid: () => t`Invalid email`,
|
||||
@@ -2664,5 +2664,27 @@ Use this if changes from other devices are not appearing on this device. This wi
|
||||
deleteAttachment: () => t`Delete attachment`,
|
||||
deleteAttachmentConfirm: () =>
|
||||
t`Are you sure you want to delete this attachment?`,
|
||||
attachmentDeleted: () => t`Attachment deleted`
|
||||
attachmentDeleted: () => t`Attachment deleted`,
|
||||
titleIsRequired: () => t`Title is required`,
|
||||
nameIsRequired: () => t`Name is required.`,
|
||||
currentPasswordRequired: () => t`Current password required`,
|
||||
loginToRestoreAttachments: () => t`Login required to restore attachments`,
|
||||
loginToRestoreAttachmentsDesc: () =>
|
||||
t`You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).
|
||||
|
||||
Continue without attachments?`,
|
||||
pleaseLoginToDownloadAttachments: () =>
|
||||
t`Please login to download attachments.`,
|
||||
attachingFiles: () => t`Attaching files`,
|
||||
closeCountdown: (seconds: number) => t`Close (${seconds})`,
|
||||
compressing: () => t`Compressing`,
|
||||
encrypting: () => t`Encrypting`,
|
||||
fileSizeLimitExceededPleaseUpgrade: () =>
|
||||
t`File size limit exceeded. Please upgrade your plan.`,
|
||||
compressionFailed: () => t`Compression failed`,
|
||||
openingLocalFile: () => t`Opening local file`,
|
||||
openingLocalFileDesc: (filePath: string) =>
|
||||
t`Are you sure you want to open this file: ${filePath}?`,
|
||||
cantOpenFileLinksInBrowsers: () =>
|
||||
t`File links cannot be opened in browsers. Please use the Notesnook desktop app.`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user