Compare commits

..

1 Commits

Author SHA1 Message Date
01zulfi
26afe29e1c common: don't prefix attachment hash in filenames during export
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-05 16:37:32 +05:00
104 changed files with 1030 additions and 2816 deletions

View File

@@ -38,11 +38,6 @@ 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

View File

@@ -19,11 +19,6 @@ 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

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/desktop",
"version": "3.3.20",
"version": "3.3.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.3.20",
"version": "3.3.16",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.3.20",
"version": "3.3.16",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",

View File

@@ -33,17 +33,16 @@ 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 { existsSync, mkdirSync, writeFileSync } from "fs";
import { 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, isPortable, isSnap } from "../utils";
import { isFlatpak, 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();
@@ -61,7 +60,6 @@ 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 }) => {
@@ -193,28 +191,9 @@ export const osIntegrationRouter = t.router({
}),
openPath: t.procedure
.input(z.object({ type: z.literal("path"), link: z.string() }))
.query(async ({ input }) => {
if (isFlatpak()) return;
.query(({ input }) => {
const { type, link } = input;
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);
if (type === "path") return shell.openPath(resolvePath(link));
}),
bringToFront: t.procedure.query(() => bringToFront()),
changeTheme: t.procedure
@@ -319,7 +298,3 @@ function toMenuItem(
}
}
}
function wrapPath(path: string, maxLineLength = 100): string {
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
}

View File

@@ -24,7 +24,6 @@ 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 };
@@ -32,15 +31,13 @@ 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(
() => updatesSupported && config.automaticUpdates
),
autoUpdates: t.procedure.query(() => config.automaticUpdates),
releaseTrack: t.procedure.query(() => config.releaseTrack),
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
download: t.procedure.query(async () => {
if (!updatesSupported || cancellationToken) return;
if (cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<string[]>((resolve, reject) => {
downloadTimeout = setTimeout(async () => {
@@ -55,7 +52,7 @@ export const updaterRouter = t.router({
});
}),
check: t.procedure.query(async () => {
if (!updatesSupported || cancellationToken) return;
if (cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<void>((resolve) => {
downloadTimeout = setTimeout(async () => {

View File

@@ -33,7 +33,3 @@ export function isFlatpak() {
export function isSnap() {
return process.env.SNAP !== undefined;
}
export function isPortable() {
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined;
}

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3105
versionCode 3102
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')

View File

@@ -44,6 +44,7 @@ import { useUserStore } from "./stores/use-user-store";
import RNBootSplash from "react-native-bootsplash";
import AppLocked from "./components/app-lock";
import { useSettingStore } from "./stores/use-setting-store";
import ScreenGuardModule from "react-native-screenguard";
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
I18nManager.swapLeftAndRightInRTL(false);
@@ -51,6 +52,7 @@ const { appLockEnabled, appLockMode } = SettingsService.get();
if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
ScreenGuardModule.initSettings();
RNBootSplash.hide({
fade: true
});

View File

@@ -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 { TextInput, View } from "react-native";
import { View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import { ScrollView } from "react-native-gesture-handler";
import { db } from "../../common/database";
@@ -59,7 +59,6 @@ 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,
@@ -154,100 +153,50 @@ const Actions = ({
{
name: strings.rename(),
onPress: () => {
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);
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()
});
},
icon: "form-textbox"
},
{
name: strings.delete(),
onPress: async () => {
close?.();
setTimeout(() => {
presentDialog({
title: strings.deleteAttachment(),
paragraph: strings.deleteAttachmentConfirm(),
positiveText: strings.yes(),
negativeText: strings.no(),
positiveType: "errorShade",
positivePress: async () => {
try {
const relations = await db.relations
.to(attachment, "note")
.get();
await db.attachments.remove(attachment.hash, false);
ToastManager.show({
type: "success",
message: strings.attachmentDeleted()
const relations = await db.relations.to(attachment, "note").get();
await db.attachments.remove(attachment.hash, false);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused = useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused =
useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
} else {
editorController.current.commands.setLoading(
true,
tab.id
);
}
});
});
return true;
} catch (e) {
return false;
} else {
editorController.current.commands.setLoading(true, tab.id);
}
}
});
});
}, 500);
close?.();
},
icon: "delete-outline"
}

View File

@@ -18,7 +18,6 @@ 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";
@@ -27,44 +26,42 @@ 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 FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Input from "../ui/input";
import { Notice } from "../ui/notice";
import Paragraph from "../ui/typography/paragraph";
import { TextInput } from "react-native-gesture-handler";
export const ChangePassword = () => {
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
oldPassword: "",
password: ""
})
);
const oldPasswordInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const oldPasswordInputRef = useRef<TextInput>(null);
const oldPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
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) {
setError(strings.emailNotConfirmedDesc());
ToastManager.show({
heading: strings.emailNotConfirmed(),
message: strings.emailNotConfirmedDesc(),
type: "error",
context: "local"
});
return;
}
if (!formRef.current.validate()) {
if (error || !oldPassword.current || !password.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const result = await BackupService.run(
@@ -77,8 +74,8 @@ export const ChangePassword = () => {
}
const passwordChanged = await db.user.changePassword(
values.oldPassword,
values.password
oldPassword.current,
password.current
);
if (!passwordChanged) {
@@ -94,15 +91,15 @@ export const ChangePassword = () => {
Navigation.goBack();
eSendEvent(eOpenRecoveryKeyDialog);
} catch (e) {
const message = (e as Error).message;
setLoading(false);
if (/old password/i.test(message)) {
formRef.current.setError("oldPassword", message);
} else {
setError(message);
}
ToastManager.show({
heading: strings.passwordChangeFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
}
setLoading(false);
};
return (
@@ -113,61 +110,36 @@ export const ChangePassword = () => {
}}
>
<Dialog context="change-password-dialog" />
<FormInput
name="oldPassword"
formRef={formRef}
<Input
fwdRef={oldPasswordInputRef}
loading={loading}
validators={[validators.required(strings.currentPasswordRequired())]}
onChangeText={(value) => {
oldPassword.current = value;
}}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.currentPassword()}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
placeholder={strings.oldPassword()}
/>
<FormInput
name="password"
formRef={formRef}
<Input
fwdRef={passwordInputRef}
loading={loading}
validators={[validators.required(strings.passwordRequired())]}
onChangeText={(value) => {
password.current = value;
}}
onErrorCheck={(e) => setError(e)}
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 }} />

View File

@@ -27,7 +27,7 @@ import { useThemeColors } from "@notesnook/theme";
import DialogHeader from "../dialog/dialog-header";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Input from "../ui/input";
import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
@@ -36,22 +36,21 @@ import { DefaultAppStyles } from "../../utils/styles";
export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
const { colors } = useThemeColors("sheet");
const formRef = useRef(
createFormRef({
email: userEmail || ""
})
);
const email = useRef<string>(userEmail);
const emailInputRef = useRef<TextInput>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const sendRecoveryEmail = async () => {
if (formRef.current.validateField("email")) {
if (!email.current || error) {
ToastManager.show({
heading: strings.emailRequired(),
type: "error",
context: "local"
});
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const lastRecoveryEmailTime = SettingsService.get().lastRecoveryEmailTime;
@@ -61,7 +60,7 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
) {
throw new Error(strings.pleaseWaitBeforeSendEmail());
}
await db.user.recoverAccount(values.email.toLowerCase());
await db.user.recoverAccount(email.current.toLowerCase());
SettingsService.set({
lastRecoveryEmailTime: Date.now()
});
@@ -76,7 +75,12 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
setSent(true);
} catch (e) {
setLoading(false);
formRef.current.setError("email", (e as Error).message);
ToastManager.show({
heading: strings.recoveryEmailFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
}
};
@@ -122,25 +126,22 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
<DialogHeader title={strings.accountRecovery()} />
<Seperator />
<FormInput
name="email"
formRef={formRef}
<Input
fwdRef={emailInputRef}
loading={loading}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
sendRecoveryEmail();
}}
onSubmit={() => {}}
/>
<Button

View File

@@ -37,9 +37,8 @@ import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { Progress } from "../sheets/progress";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import FormInput, { validators } from "../ui/input/form-input";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
@@ -64,13 +63,14 @@ export const Login = ({
const {
step,
setStep,
password,
email,
emailInputRef,
passwordInputRef,
loading,
setLoading,
login,
error,
formRef
setError,
login
} = useLogin(async () => {
eSendEvent(eUserLoggedIn, true);
await sleep(500);
@@ -93,11 +93,6 @@ export const Login = ({
});
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const onContinue = () => {
login();
};
useEffect(() => {
async () => {
setStep(LoginSteps.emailAuth);
@@ -203,27 +198,27 @@ export const Login = ({
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<FormInput
name="email"
formRef={formRef}
<Input
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
validationType="email"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
defaultValue={email.current}
editable={step === LoginSteps.emailAuth && !loading}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
onSubmit={() => {
if (step === LoginSteps.emailAuth) {
onContinue();
login();
} else {
passwordInputRef.current?.focus();
}
@@ -232,10 +227,11 @@ export const Login = ({
{step === LoginSteps.passwordAuth && (
<>
<FormInput
name="password"
formRef={formRef}
<Input
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
@@ -246,9 +242,9 @@ export const Login = ({
placeholder={strings.password()}
marginBottom={0}
editable={!loading}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
defaultValue={password.current}
onSubmit={() => {
login();
}}
/>
<Button
@@ -259,13 +255,9 @@ export const Login = ({
paddingHorizontal: 0
}}
onPress={() => {
if (loading) return;
if (loading || !email.current) return;
presentSheet({
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
component: <ForgotPassword userEmail={email.current} />
});
}}
textStyle={{
@@ -281,7 +273,8 @@ export const Login = ({
<Button
loading={loading}
onPress={() => {
onContinue();
if (loading) return;
login();
}}
style={{
width: "100%"
@@ -335,25 +328,6 @@ export const Login = ({
</Paragraph>
</TouchableOpacity>
) : null}
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
</View>
</View>
</View>

View File

@@ -43,26 +43,29 @@ import SheetProvider from "../sheet-provider";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { LoginSteps, useLogin } from "./use-login";
import { strings } from "@notesnook/intl";
import { getObfuscatedEmail } from "../../utils/functions";
import { DefaultAppStyles } from "../../utils/styles";
import FormInput, { validators } from "../ui/input/form-input";
export const SessionExpired = () => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
const [focused, setFocused] = useState(false);
const { step, passwordInputRef, loading, login, formRef } = useLogin(() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
}, true);
const { step, password, email, passwordInputRef, loading, login } = useLogin(
() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
},
true
);
const logout = async () => {
try {
@@ -101,7 +104,7 @@ export const SessionExpired = () => {
if (!complete) {
const user = await db.user.getUser();
if (!user) return;
formRef.current.setValue("email", user.email);
email.current = user.email;
setVisible(true);
setFocused(false);
return;
@@ -114,14 +117,14 @@ export const SessionExpired = () => {
} catch (e) {
const user = await db.user.getUser();
if (!user) return;
formRef.current.setValue("email", user.email);
email.current = user.email;
setFocused(false);
setVisible(true);
useUserStore.setState({
disableAppLockRequests: true
});
}
}, [formRef]);
}, [email]);
useEffect(() => {
const sub = eSubscribeEvent(eLoginSessionExpired, open);
@@ -152,7 +155,6 @@ export const SessionExpired = () => {
enableSheetKeyboardHandler={true}
visible={true}
>
<Dialog context="two_factor_verify" />
<View
style={{
width: focused ? "100%" : "99.9%",
@@ -190,17 +192,17 @@ export const SessionExpired = () => {
}}
>
{strings.sessionExpiredDesc(
getObfuscatedEmail(formRef.current.getValue("email") as string)
getObfuscatedEmail(email.current as string)
)}
</Paragraph>
</View>
{step === LoginSteps.passwordAuth ? (
<FormInput
<Input
fwdRef={passwordInputRef}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
onChangeText={(value) => {
password.current = value;
}}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
@@ -208,7 +210,7 @@ export const SessionExpired = () => {
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
onSubmitEditing={() => {
onSubmit={() => {
login();
}}
/>

View File

@@ -30,6 +30,7 @@ import {
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import { clearMessage, setEmailVerifyMessage } from "../../services/message";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
@@ -38,14 +39,13 @@ import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Loading } from "../loading";
import { Button } from "../ui/button";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { AuthHeader } from "./header";
import { SignupContext } from "./signup-context";
import { RouteParams } from "../../stores/use-navigation-store";
import SettingsService from "../../services/settings";
import AppIcon from "../ui/AppIcon";
const SignupSteps = {
signup: 0,
@@ -62,17 +62,13 @@ export const Signup = ({
}) => {
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
email: "",
password: "",
confirmPassword: ""
})
);
const email = useRef<string>(undefined);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const confirmPasswordInputRef = useRef<TextInput>(null);
const [errorMessage, setErrorMessage] = useState<string>();
const confirmPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const setLastSynced = useUserStore((state) => state.setLastSynced);
@@ -80,17 +76,29 @@ export const Signup = ({
const isTablet = width > 600;
const route = useRoute<RouteProp<RouteParams, "Auth">>();
const signup = async () => {
setErrorMessage(undefined);
if (!formRef.current.validate()) return;
if (loading) return;
const validateInfo = () => {
if (!password.current || !email.current || !confirmPassword.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
const values = formRef.current.getValues();
return false;
}
return true;
};
const signup = async () => {
if (!validateInfo() || error) return;
if (loading) return;
setLoading(true);
try {
setCurrentStep(SignupSteps.createAccount);
await db.user.signup(values.email.toLowerCase(), values.password);
await db.user.signup(email.current!.toLowerCase(), password.current!);
const user = await db.user.getUser();
setUser(user);
setLastSynced(await db.lastSynced());
@@ -107,14 +115,12 @@ export const Signup = ({
} catch (e) {
setCurrentStep(SignupSteps.signup);
setLoading(false);
if (
(e as Error).message === "Unable to create an account on this email."
) {
formRef.current.setError("email", (e as Error).message);
} else {
setErrorMessage((e as Error).message);
}
ToastManager.show({
heading: strings.signupFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
return false;
}
};
@@ -209,55 +215,60 @@ export const Signup = ({
alignSelf: "center"
}}
>
<FormInput
name="email"
formRef={formRef}
<Input
fwdRef={emailInputRef}
loading={loading}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
blurOnSubmit={false}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
onSubmit={() => {
if (!email.current) return;
passwordInputRef.current?.focus();
}}
/>
<FormInput
name="password"
formRef={formRef}
<Input
fwdRef={passwordInputRef}
loading={loading}
onChangeText={(value) => {
password.current = value;
}}
defaultValue={password.current}
testID="input.password"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
blurOnSubmit={false}
validationType="password"
autoCorrect={false}
placeholder={strings.password()}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onSubmit={() => {
if (!password.current) return;
confirmPasswordInputRef.current?.focus();
}}
/>
<FormInput
name="confirmPassword"
formRef={formRef}
<Input
fwdRef={confirmPasswordInputRef}
loading={loading}
onChangeText={(value) => {
confirmPassword.current = value;
}}
defaultValue={confirmPassword.current}
testID="input.confirmPassword"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Signup"
returnKeyType="done"
secureTextEntry
@@ -265,22 +276,17 @@ export const Signup = ({
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
validationType="confirmPassword"
customValidator={() => password.current!}
placeholder={strings.confirmPassword()}
marginBottom={12}
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
"password",
strings.passwordNotMatched()
)
]}
onSubmitEditing={() => {
onSubmit={() => {
signup();
}}
/>
<Button
title={!loading ? strings.continue() : null}
title={!loading ? "Continue" : null}
type="accent"
loading={loading}
onPress={() => {
@@ -314,25 +320,6 @@ export const Signup = ({
</Paragraph>
</Paragraph>
</TouchableOpacity>
{errorMessage ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{errorMessage}
</Paragraph>
) : null}
</View>
<View

View File

@@ -27,15 +27,14 @@ import useTimer from "../../hooks/use-timer";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { eCloseSimpleDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { presentDialog } from "../dialog/functions";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";
import { presentDialog } from "../dialog/functions";
type MFAInfo = {
primaryMethod: string;
@@ -53,8 +52,7 @@ const TwoFactorVerification = ({
method: string;
code: string;
},
callback: (result: any) => void,
onerror: (e: Error) => void
callback: (result: any) => void
) => Promise<void>;
mfaInfo: MFAInfo;
onCancel: () => void;
@@ -68,26 +66,15 @@ const TwoFactorVerification = ({
method: mfaInfo?.primaryMethod,
isPrimary: true
});
const { seconds, start, reset, secondsRef } = useTimer(currentMethod.method!);
const { seconds, start, reset } = useTimer(currentMethod.method!);
const [loading, setLoading] = useState(false);
const inputRef = useRef<TextInput>(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const onNext = async () => {
if (!code.current || code.current.length < 6) {
setError(
new Error("Please provide a valid multi-factor authentication code.")
);
if (!code.current || code.current.length < 6 || !currentMethod.method)
return;
}
if (!currentMethod.method) {
return;
}
setLoading(true);
setError(undefined);
inputRef.current?.blur();
await onMfaLogin(
{
@@ -99,9 +86,6 @@ const TwoFactorVerification = ({
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
}
setLoading(false);
},
(e) => {
setError(e);
}
);
setLoading(false);
@@ -147,7 +131,7 @@ const TwoFactorVerification = ({
};
const onSendCode = useCallback(async () => {
if (secondsRef.current || sending) return;
if (seconds || sending) return;
setSending(true);
try {
await db.mfa.sendCode(currentMethod.method as "sms" | "email");
@@ -155,18 +139,15 @@ const TwoFactorVerification = ({
setSending(false);
} catch (e) {
setSending(false);
setError(
new Error(`Error sending 2FA Code. Tap "Send code" to try again `)
);
ToastManager.error(e as Error, "Error sending 2FA Code", "local");
}
}, [currentMethod.method, secondsRef, sending, start]);
}, [currentMethod.method, mfaInfo.token, seconds, sending, start]);
useEffect(() => {
if (currentMethod.method === "sms" || currentMethod.method === "email") {
onSendCode();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMethod.method]);
}, [currentMethod.method, onSendCode]);
return (
<ScrollView
@@ -253,7 +234,6 @@ const TwoFactorVerification = ({
fwdRef={inputRef}
textAlign="center"
onChangeText={(value) => {
setError(undefined);
code.current = value;
}}
cursorColor={colors.selected.accent}
@@ -261,7 +241,6 @@ const TwoFactorVerification = ({
selectionColor={colors.selected.accent}
onSubmitEditing={onNext}
height={60}
marginBottom={0}
inputStyle={{
fontSize: AppFontSize.lg,
textAlign: "center",
@@ -275,26 +254,10 @@ const TwoFactorVerification = ({
containerStyle={{
minWidth: "50%"
}}
wrapperStyle={{
height: 60
}}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
maxWidth: 250
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error?.message}
</Paragraph>
) : null}
<Button
title={loading ? null : strings.next()}
@@ -375,8 +338,7 @@ TwoFactorVerification.present = (
method: string;
code: string;
},
callback: (result: any) => void,
onerror: (e: Error) => void
callback: (result: any) => void
) => Promise<void>,
data: MFAInfo,
onCancel: () => void,

View File

@@ -28,7 +28,6 @@ import SettingsService from "../../services/settings";
import { useUserStore } from "../../stores/use-user-store";
import { eCloseSimpleDialog } from "../../utils/events";
import TwoFactorVerification from "./two-factor";
import { createFormRef } from "../ui/input/form-input";
export const LoginSteps = {
emailAuth: 1,
@@ -40,37 +39,49 @@ export const useLogin = (
onFinishLogin?: () => void,
sessionExpired = false
) => {
const [error, setError] = useState<Error>();
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const [step, setStep] = useState(LoginSteps.emailAuth);
const email = useRef<string>(undefined);
const password = useRef<string>(undefined);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const formRef = useRef(
createFormRef({
email: "",
password: ""
})
);
const validateInfo = () => {
if (
(!password.current && step === LoginSteps.passwordAuth) ||
(!email.current && step === LoginSteps.emailAuth)
) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return false;
}
return true;
};
const login = async () => {
if (!validateInfo() || error) return;
try {
if (loading) return;
setError(undefined);
setLoading(true);
switch (step) {
case LoginSteps.emailAuth: {
if (formRef.current.validateField("email")) {
if (!email.current) {
setLoading(false);
return;
}
const mfaInfo = await db.user.authenticateEmail(
formRef.current.getValue("email")
);
const mfaInfo = await db.user.authenticateEmail(email.current);
if (mfaInfo) {
TwoFactorVerification.present(
async (mfa: any, callback: (success: boolean) => void, onerror: (e: Error) => void) => {
async (mfa: any, callback: (success: boolean) => void) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
@@ -92,9 +103,6 @@ export const useLogin = (
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
ToastManager.error(new Error("Token expired, try logging in again"));
} else {
onerror(e as Error);
}
}
},
@@ -111,14 +119,13 @@ export const useLogin = (
break;
}
case LoginSteps.passwordAuth: {
if (!formRef.current.validate()) {
if (!email.current || !password.current) {
setLoading(false);
return;
}
const values = formRef.current.getValues();
await db.user.authenticatePassword(
values.email,
values.password,
email.current,
password.current,
undefined,
sessionExpired
);
@@ -135,11 +142,12 @@ export const useLogin = (
const finishWithError = async (e: Error) => {
if (e.message === "invalid_grant") setStep(LoginSteps.emailAuth);
setLoading(false);
if (e.message === "Password is incorrect.") {
formRef.current.setError("password", e.message);
} else {
setError(e);
}
ToastManager.show({
heading: strings.loginFailed(),
message: e.message,
type: "error",
context: "local"
});
};
const finishLogin = async () => {
@@ -166,12 +174,13 @@ export const useLogin = (
login,
step,
setStep,
email,
password,
passwordInputRef,
emailInputRef,
loading,
setLoading,
error,
setError,
formRef
setError
};
};

View File

@@ -136,7 +136,7 @@ const BaseDialog = ({
? background
: transparent
? "transparent"
: "rgba(0,0,0,0.1)"
: "rgba(0,0,0,0.3)"
}}
>
<KeyboardAvoidingView

View File

@@ -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 { ActivityIndicator, StyleSheet, View } from "react-native";
import { 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,11 +79,6 @@ const DialogButtons = ({
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
@@ -110,6 +105,7 @@ const DialogButtons = ({
style={{
marginLeft: 10
}}
loading={loading}
bold
type={positiveType || "transparent"}
title={positiveTitle}

View File

@@ -17,12 +17,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { KeyboardTypeOptions, TextInput } from "react-native";
import { KeyboardTypeOptions } from "react-native";
import { eSendEvent } from "../../services/event-manager";
import { eCloseSimpleDialog, eOpenSimpleDialog } from "../../utils/events";
import { ButtonProps } from "../ui/button";
import { FieldValidator, FormRef } from "../ui/input/form-input";
import { RefObject } from "react";
export type DialogInfo = {
title?: string;
@@ -45,18 +43,6 @@ export type DialogInfo = {
| "errorShade";
icon?: string;
paragraphColor: string;
form?: {
formRef: FormRef;
items: {
name: string;
placeholder: string;
label?: string;
validators: FieldValidator[];
defaultValue?: string;
ref: RefObject<TextInput | null>;
}[];
onFormSubmit?: (form: FormRef) => Promise<boolean>;
};
input: boolean;
inputPlaceholder: string;
defaultValue: string;

View File

@@ -18,13 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, {
useCallback,
useEffect,
useRef,
useState,
RefObject
} from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { TextInput, View, ViewStyle } from "react-native";
import { DDS } from "../../services/device-detection";
import {
@@ -40,7 +34,6 @@ import { sleep } from "../../utils/time";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import Input from "../ui/input";
import { FormInput, type FormRef } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Seperator from "../ui/seperator";
import BaseDialog from "./base-dialog";
@@ -60,38 +53,15 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
});
const inputRef = useRef<TextInput>(null);
const [dialogInfo, setDialogInfo] = useState<DialogInfo>();
const formRef = useRef(dialogInfo?.form?.formRef);
formRef.current = dialogInfo?.form?.formRef;
const onPressPositive = async () => {
// Handle form submission if form is available
if (dialogInfo?.form && formRef.current) {
inputRef.current?.blur();
try {
const isValid = await formRef.current.validate();
if (!isValid) {
return;
}
if (dialogInfo.form.onFormSubmit) {
setLoading(true);
const result = await dialogInfo.form.onFormSubmit(formRef.current);
if (result === false) {
setLoading(false);
return;
}
}
} catch (e) {
/** Empty */
}
setLoading(false);
} else if (dialogInfo?.positivePress) {
// Handle old input-based submission
if (dialogInfo?.positivePress) {
inputRef.current?.blur();
setLoading(true);
let result = false;
try {
result = await dialogInfo.positivePress(
values.current.inputValue,
values.current.inputValue || dialogInfo.defaultValue,
checked
);
} catch (e) {
@@ -106,7 +76,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
};
@@ -116,7 +85,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
if (data.context !== context) return;
setDialogInfo(data);
setChecked(data.check?.defaultValue);
formRef.current = data?.form?.formRef;
values.current.inputValue = data.defaultValue;
setVisible(true);
},
@@ -126,7 +94,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
const hide = React.useCallback(() => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
setDialogInfo(undefined);
dialogInfo?.onClose?.();
@@ -167,30 +134,19 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
? false
: dialogInfo.statusBarTranslucent
}
bounce={!dialogInfo.input && !dialogInfo.form}
bounce={!dialogInfo.input}
closeOnTouch={!dialogInfo.disableBackdropClosing}
background={dialogInfo.background}
transparent={
dialogInfo.transparent === undefined ? false : dialogInfo.transparent
dialogInfo.transparent === undefined ? true : dialogInfo.transparent
}
onShow={async () => {
if (dialogInfo.input && !dialogInfo.form) {
if (dialogInfo.input) {
inputRef.current?.setNativeProps({
text: dialogInfo.defaultValue
});
await sleep(300);
inputRef.current?.focus();
} else if (dialogInfo.form) {
const items = dialogInfo.form?.items;
const firstItem = items[0];
for (const item of items) {
if (item.defaultValue) {
item.ref.current?.setNativeProps({
text: dialogInfo.defaultValue
});
}
}
firstItem?.ref?.current?.focus();
}
}}
visible={true}
@@ -214,36 +170,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
/>
<Seperator half />
{dialogInfo.form ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP / 2
}}
>
{dialogInfo.form.items.map((item, index) => (
<FormInput
key={item.name}
fwdRef={item.ref}
name={item.name}
autoFocus={index === 0}
placeholder={item.placeholder}
formRef={formRef as RefObject<FormRef>}
validators={item.validators}
defaultValue={item.defaultValue}
secureTextEntry={dialogInfo.secureTextEntry}
onSubmitEditing={() => {
const nextItem = dialogInfo?.form?.items?.[index + 1];
if (nextItem) {
nextItem?.ref.current?.focus();
} else {
onPressPositive();
}
}}
/>
))}
</View>
) : dialogInfo.input ? (
{dialogInfo.input ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
@@ -257,7 +184,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}}
testID="input-value"
secureTextEntry={dialogInfo.secureTextEntry}
defaultValue={dialogInfo.defaultValue}
//defaultValue={dialogInfo.defaultValue}
onSubmit={() => {
onPressPositive();
}}
@@ -310,10 +237,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
<DialogButtons
onPressNegative={onNegativePress}
onPressPositive={
(dialogInfo.positivePress || dialogInfo.form?.onFormSubmit) &&
onPressPositive
}
onPressPositive={dialogInfo.positivePress && onPressPositive}
loading={loading}
positiveTitle={dialogInfo.positiveText}
negativeTitle={dialogInfo.negativeText}

View File

@@ -17,12 +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 {
GroupHeader,
GroupingKey,
GroupOptions,
ItemType
} from "@notesnook/core";
import { GroupHeader, GroupOptions, ItemType } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
@@ -45,9 +40,7 @@ type SectionHeaderProps = {
color?: string;
screen?: RouteName;
groupOptions: GroupOptions;
group: GroupingKey;
onOpenJumpToDialog: () => void;
itemCount?: number;
};
export const SectionHeader = React.memo<
@@ -60,13 +53,11 @@ export const SectionHeader = React.memo<
color,
screen,
groupOptions,
group,
onOpenJumpToDialog,
itemCount
onOpenJumpToDialog
}: SectionHeaderProps) {
const { colors } = useThemeColors();
const isCompactModeEnabled = useIsCompactModeEnabled(
dataType as "note" | "notebook" | "searchResult"
dataType as "note" | "notebook"
);
return (
@@ -113,9 +104,7 @@ export const SectionHeader = React.memo<
color={color || colors.primary.accent}
>
{!item.title || item.title === ""
? screen === "Search"
? strings.results(itemCount || 0)
: strings.pinned().toUpperCase()
? strings.pinned().toUpperCase()
: item.title.toUpperCase()}
</Heading>
</Pressable>
@@ -144,7 +133,6 @@ export const SectionHeader = React.memo<
<Sort
screen={screen}
type={dataType}
group={group}
hideGroupOptions={
screen === "Reminders" || screen === "Search"
}
@@ -162,8 +150,7 @@ export const SectionHeader = React.memo<
hidden={
dataType !== "note" &&
dataType !== "notebook" &&
screen !== "Notes" &&
screen !== "Search"
screen !== "Notes"
}
style={{
width: 25,
@@ -176,11 +163,9 @@ export const SectionHeader = React.memo<
}
onPress={() => {
SettingsService.set({
[dataType === "notebook"
? "notebooksListMode"
: dataType === "searchResult"
? "searchListMode"
: "notesListMode"]: !isCompactModeEnabled
[dataType !== "notebook"
? "notesListMode"
: "notebooksListMode"]: !isCompactModeEnabled
? "compact"
: "normal"
});
@@ -206,7 +191,6 @@ export const SectionHeader = React.memo<
},
(prev, next) => {
if (prev.item.title !== next.item.title) return false;
if (prev.itemCount !== next.itemCount) return false;
if (prev.groupOptions?.groupBy !== next.groupOptions.groupBy) return false;
if (prev.groupOptions?.sortDirection !== next.groupOptions.sortDirection)
return false;

View File

@@ -31,7 +31,6 @@ import { eSendEvent } from "../../../services/event-manager";
import { eOnLoadNote } from "../../../utils/events";
import { IconButton } from "../../ui/icon-button";
import { fluidTabsRef } from "../../../utils/global-refs";
import { useSettingStore } from "../../../stores/use-setting-store";
type SearchResultProps = {
item: HighlightedResult;
};
@@ -39,9 +38,6 @@ type SearchResultProps = {
export const SearchResult = (props: SearchResultProps) => {
const [expanded, setExpanded] = React.useState(true);
const { colors } = useThemeColors();
const compactMode = useSettingStore(
(state) => state.settings.searchListMode === "compact"
);
const openNote = async (index?: number) => {
const note = await db.notes.note(props.item.id);
@@ -91,7 +87,7 @@ export const SearchResult = (props: SearchResultProps) => {
flexShrink: 1
}}
>
{props.item.content?.length && !compactMode ? (
{props.item.content?.length ? (
<IconButton
name={!expanded ? "chevron-right" : "chevron-down"}
onPress={() => setExpanded((prev) => !prev)}
@@ -134,7 +130,6 @@ export const SearchResult = (props: SearchResultProps) => {
</View>
{expanded &&
!compactMode &&
props.item.content.map((content, index) => (
<Pressable
key={props.item.id + index}

View File

@@ -53,7 +53,6 @@ type ListProps = {
isRenderedInActionSheet?: boolean;
CustomListComponent?: React.JSX.ElementType;
placeholder?: PlaceholderData;
groupType: GroupingKey;
id?: string;
};
@@ -74,7 +73,16 @@ export default function List(props: ListProps) {
props.dataType === "notebook" ||
notebooksListMode === "compact";
const groupOptions = useGroupOptions(props.groupType);
const groupType =
props.renderedInRoute === "Notes"
? "home"
: props.renderedInRoute === "Favorites"
? "favorites"
: props.renderedInRoute === "Trash" || props.dataType === "trash"
? "trash"
: `${props.dataType}s`;
const groupOptions = useGroupOptions(groupType);
const _onRefresh = async () => {
Sync.run("global", false, "full", () => {
@@ -97,7 +105,7 @@ export default function List(props: ListProps) {
isSheet={props.isRenderedInActionSheet || false}
items={props.data}
groupOptions={groupOptions}
group={props.groupType as GroupingKey}
group={groupType as GroupingKey}
renderedInRoute={props.renderedInRoute}
customAccentColor={props.customAccentColor}
dataType={props.dataType}
@@ -107,7 +115,7 @@ export default function List(props: ListProps) {
},
[
groupOptions,
props.groupType,
groupType,
props.customAccentColor,
props.data,
props.dataType,

View File

@@ -53,7 +53,7 @@ import TagItem from "../list-items/tag";
import { SearchResult } from "../list-items/search-result";
type ListItemWrapperProps<TItem = Item> = {
group: GroupingKey;
group?: GroupingKey;
items: VirtualizedGrouping<TItem> | undefined;
isSheet: boolean;
index: number;
@@ -182,7 +182,6 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
item={groupHeader}
index={index}
dataType={item.type}
group={group}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -219,7 +218,6 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
item={groupHeader}
index={index}
dataType={item.type}
group={group}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -247,7 +245,6 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
screen={props.renderedInRoute}
item={groupHeader}
index={index}
group={group}
dataType={item.type}
color={props.customAccentColor}
groupOptions={groupOptions}
@@ -274,7 +271,6 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
screen={props.renderedInRoute}
item={groupHeader}
index={index}
group={group}
dataType={item.type}
color={props.customAccentColor}
groupOptions={groupOptions}
@@ -301,11 +297,9 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
screen={props.renderedInRoute}
item={groupHeader}
index={index}
group={group}
dataType={item.type}
color={props.customAccentColor}
groupOptions={groupOptions}
itemCount={items?.placeholders.length}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
ref: props.scrollRef,

View File

@@ -121,17 +121,6 @@ export const RelationsList = ({
<List
data={items}
loading={false}
groupType={
referenceType === "note"
? "notes"
: referenceType === "tag"
? "tags"
: referenceType === "notebook"
? "notebooks"
: referenceType === "reminder"
? "reminders"
: "notes"
}
dataType={referenceType as any}
isRenderedInActionSheet={true}
/>

View File

@@ -181,7 +181,6 @@ export default function ReminderNotify({
data={references}
loading={false}
dataType="note"
groupType="notes"
isRenderedInActionSheet={true}
/>
</View>

View File

@@ -45,16 +45,25 @@ import Paragraph from "../../ui/typography/paragraph";
const Sort = ({
type,
screen,
hideGroupOptions,
group: groupType
hideGroupOptions
}: {
type: ItemType;
screen?: RouteName;
group: GroupingKey;
hideGroupOptions?: boolean;
}) => {
const { colors } = useThemeColors();
const groupType =
screen === "Archive"
? "archive"
: screen === "Search"
? "search"
: screen === "Notes"
? "home"
: screen === "Trash" || type === "trash"
? "trash"
: ((type + "s") as GroupingKey);
const [groupOptions, setGroupOptions] = useState(
db.settings.getGroupOptions(groupType)
);
@@ -148,15 +157,10 @@ const Sort = ({
>
{Object.keys(SORT).map((item) => {
const sortOptionVisibility = {
dateCreated: groupType !== "trash",
relevance: groupType === "search",
dueDate: groupType === "reminders",
dateModified: groupType === "reminders" || groupType === "tags",
dateEdited:
groupType !== "tags" &&
groupType !== "reminders" &&
groupType !== "trash",
dateDeleted: groupType === "trash"
relevance: screen === "Search",
dueDate: screen === "Reminders",
dateModified: screen === "Tags" || screen === "Reminders",
dateEdited: screen !== "Tags" && screen !== "Reminders"
};
// Check if this sort option should be skipped for the current screen
@@ -184,7 +188,10 @@ const Sort = ({
onPress={async () => {
const _groupOptions: GroupOptions = {
...groupOptions,
sortBy: item as SortOptions["sortBy"]
sortBy:
type === "trash"
? "dateDeleted"
: (item as SortOptions["sortBy"])
};
await updateGroupOptions(_groupOptions);
}}

View File

@@ -433,7 +433,7 @@ const TabBar = (props: SimpleTabBarProps) => {
name="plus"
testID="sidebar-add-button"
size={AppFontSize.lg - 2}
top={10}
top={10}
color={colors.primary.icon}
onPress={async () => {
if (props.navigationState.index === 1) {
@@ -498,11 +498,6 @@ const TabBar = (props: SimpleTabBarProps) => {
? "notebook"
: "tag"
}
group={
props.navigationState.index === 1
? "notebooks"
: "tags"
}
hideGroupOptions
/>
)

View File

@@ -1,411 +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 React, { RefObject, useEffect, useMemo, useState } from "react";
import {
ColorValue,
TextInput,
TextInputProps,
TextStyle,
TouchableOpacity,
View,
ViewStyle
} from "react-native";
import isEmail from "validator/lib/isEmail";
import isURL from "validator/lib/isURL";
import { useThemeColors } from "@notesnook/theme";
import { defaultBorderRadius, AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import { IconButton } from "../icon-button";
import Paragraph from "../typography/paragraph";
import AppIcon from "../AppIcon";
export type FormValues = Record<string, string>;
export type FormErrors = Partial<Record<string, string>>;
export type FieldValidator = (
value: string,
values: FormValues
) => string | undefined;
export type ValidationSchema = Partial<Record<string, FieldValidator[]>>;
export interface FormRef {
values: FormValues;
errors: FormErrors;
setValue: (name: string, value: string) => void;
getValue: (name: string) => string;
getValues: () => FormValues;
setError: (name: string, error?: string) => void;
getError: (name: string) => string | undefined;
clearErrors: () => void;
registerField: (name: string, validators?: FieldValidator[]) => void;
unregisterField: (name: string) => void;
validateField: (name: string) => string | undefined;
validate: () => boolean;
subscribe: (listener: () => void) => () => void;
}
export function createFormRef(initialValues: FormValues = {}): FormRef {
const listeners = new Set<() => void>();
const values: FormValues = { ...initialValues };
const errors: FormErrors = {};
const schema: ValidationSchema = {};
const notify = () => {
listeners.forEach((listener) => listener());
};
return {
values,
errors,
setValue(name, value) {
values[name] = value;
if (errors[name]) {
delete errors[name];
notify();
}
},
getValue(name) {
return values[name] ?? "";
},
getValues() {
return { ...values };
},
setError(name, error) {
if (!error) {
delete errors[name];
} else {
errors[name] = error;
}
notify();
},
getError(name) {
return errors[name];
},
clearErrors() {
Object.keys(errors).forEach((key) => delete errors[key]);
notify();
},
registerField(name, validators = []) {
schema[name] = validators;
if (values[name] === undefined) values[name] = "";
},
unregisterField(name) {
delete schema[name];
delete errors[name];
notify();
},
validateField(name) {
const fieldValidators = schema[name] || [];
const value = values[name] ?? "";
for (const validator of fieldValidators) {
const error = validator(value, values);
if (error) {
errors[name] = error;
notify();
return error;
}
}
delete errors[name];
notify();
return undefined;
},
validate() {
const nextErrors = validateForm(values, schema);
Object.keys(errors).forEach((key) => delete errors[key]);
Object.keys(nextErrors).forEach((key) => {
errors[key] = nextErrors[key];
});
notify();
return !hasFormErrors(nextErrors);
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
};
}
export function validateForm(
values: FormValues,
schema: ValidationSchema
): FormErrors {
const errors: FormErrors = {};
Object.keys(schema).forEach((field) => {
const fieldValidators = schema[field] || [];
const value = values[field] ?? "";
for (const validator of fieldValidators) {
const error = validator(value, values);
if (error) {
errors[field] = error;
break;
}
}
});
return errors;
}
export function hasFormErrors(errors: FormErrors) {
return Object.keys(errors).length > 0;
}
export const validators = {
required:
(message = "This field is required") =>
(value: string) =>
value?.trim() ? undefined : message,
email:
(message = "Please enter a valid email") =>
(value: string) =>
!value?.trim() || isEmail(value.trim()) ? undefined : message,
minLength: (length: number, message?: string) => (value: string) =>
!value || value.length >= length
? undefined
: message || `Must be at least ${length} characters`,
url:
(message = "Please enter a valid URL") =>
(value: string) =>
!value?.trim() || isURL(value.trim(), { allow_underscores: true })
? undefined
: message,
matchField:
(fieldName: string, message = "Values do not match") =>
(value: string, values: FormValues) =>
value === values[fieldName] ? undefined : message
};
interface FormInputProps extends TextInputProps {
name: string;
formRef: RefObject<FormRef>;
validators?: FieldValidator[];
fwdRef?: RefObject<TextInput | null>;
loading?: boolean;
error?: string;
customColor?: ColorValue;
marginBottom?: number;
marginRight?: number;
button?: {
icon: string;
color: ColorValue;
onPress: () => void;
testID?: string;
size?: number;
};
buttons?: React.ReactNode;
buttonLeft?: React.ReactNode;
height?: number;
fontSize?: number;
inputStyle?: TextInputProps["style"];
containerStyle?: ViewStyle;
wrapperStyle?: ViewStyle;
errorStyle?: TextStyle;
}
export function FormInput({
name,
formRef,
validators: fieldValidators = [],
fwdRef,
loading,
error,
secureTextEntry,
customColor,
marginBottom = 10,
marginRight,
button,
buttonLeft,
buttons,
height = 45,
fontSize = AppFontSize.sm,
inputStyle = {},
containerStyle = {},
wrapperStyle = {},
onFocus,
onBlur,
onPress,
onChangeText,
errorStyle,
...restProps
}: FormInputProps) {
const { colors, isDark } = useThemeColors();
const [focused, setFocused] = useState(false);
const [secureEntry, setSecureEntry] = useState(true);
const [, setVersion] = useState(0);
useEffect(() => {
const form = formRef.current;
form.registerField(name, fieldValidators);
const unsubscribe = form.subscribe(() => {
setVersion((v) => v + 1);
});
return () => {
unsubscribe();
form.unregisterField(name);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formRef, name]);
const value = formRef.current.getValue(name);
const fieldError = error || formRef.current.getError(name);
const borderColor = useMemo(() => {
if (fieldError) return colors.error.accent;
if (focused) return customColor || colors.selected.border;
return colors.primary.border;
}, [colors, customColor, fieldError, focused]);
const style: ViewStyle = {
borderWidth: 1,
borderRadius: defaultBorderRadius,
borderColor,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
...containerStyle
};
const textStyle: TextInputProps["style"] = {
paddingHorizontal: 0,
fontSize,
color:
onPress && loading ? colors.primary.accent : colors.primary.paragraph,
flexGrow: 1,
flexShrink: 1,
paddingBottom: DefaultAppStyles.GAP_VERTICAL,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
fontFamily: "Inter-Regular",
...(inputStyle as ViewStyle)
};
const handleChangeText = (nextValue: string) => {
formRef.current.setValue(name, nextValue);
onChangeText?.(nextValue);
};
return (
<View
importantForAccessibility="yes"
style={{
marginBottom,
marginRight,
...wrapperStyle
}}
>
<TouchableOpacity
disabled={!loading}
onPress={onPress}
activeOpacity={1}
style={style}
>
{buttonLeft}
<TextInput
{...restProps}
defaultValue={value}
ref={fwdRef}
editable={!loading && restProps.editable !== false}
onChangeText={handleChangeText}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
keyboardAppearance={isDark ? "dark" : "light"}
style={textStyle}
secureTextEntry={secureTextEntry && secureEntry}
placeholderTextColor={colors.primary.placeholder}
/>
<View
style={{
flexDirection: "row",
justifyContent: "center",
height: 35 > height ? height : 35,
alignItems: "center"
}}
>
{secureTextEntry && (
<IconButton
name="eye"
size={20}
top={10}
bottom={10}
onPress={() => {
fwdRef?.current?.blur();
setSecureEntry(!secureEntry);
}}
style={{
width: 25,
marginLeft: 5
}}
color={
secureEntry ? colors.secondary.icon : colors.primary.accent
}
/>
)}
{buttons}
{button && (
<IconButton
testID={button.testID}
name={button.icon}
size={button.size || AppFontSize.xl}
top={10}
bottom={10}
onPress={button.onPress}
color={button.color}
style={{
marginRight: -8
}}
/>
)}
</View>
</TouchableOpacity>
{fieldError ? (
<Paragraph
size={AppFontSize.xs}
style={[{ marginTop: 5, color: colors.error.icon }, errorStyle]}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{fieldError}
</Paragraph>
) : null}
</View>
);
}
export default FormInput;

View File

@@ -121,8 +121,8 @@ const Input = ({
const color = error
? colors.error.border
: focus
? customColor || colors.selected.border
: colors.primary.border;
? customColor || colors.selected.border
: colors.primary.border;
const validate = async (value: string) => {
if (!validationType) return;

View File

@@ -368,14 +368,7 @@ export const useActions = ({
inputPlaceholder: strings.name(),
defaultValue: item.title,
positivePress: async (value) => {
if (!value || value.trim().length === 0) {
ToastManager.error(
new Error(strings.nameIsRequired()),
undefined,
"local"
);
return;
}
if (!value || value.trim().length === 0) return;
await db.colors.add({
id: item.id,
title: value

View File

@@ -95,7 +95,6 @@ import { BETA } from "../utils/constants";
import {
eAfterSync,
eCloseSheet,
eCloseSimpleDialog,
eEditorReset,
eLoginSessionExpired,
eOnLoadNote,
@@ -287,10 +286,7 @@ const onUserSubscriptionStatusChanged = async (
subscription: subscription
}
});
eSendEvent(eCloseSimpleDialog);
setTimeout(() => {
Walkthrough.present("prouser", false, true);
}, 500);
Walkthrough.present("prouser", false, true);
}
await PremiumService.setPremiumStatus();
useMessageStore.getState().setAnnouncement();

View File

@@ -20,27 +20,14 @@ import { ItemType } from "@notesnook/core";
import { useSettingStore } from "../stores/use-setting-store";
export function useIsCompactModeEnabled(dataType: ItemType) {
const [notebooksListMode, notesListMode, searchListMode] = useSettingStore(
(state) => [
state.settings.notebooksListMode,
state.settings.notesListMode,
state.settings.searchListMode
]
);
const [notebooksListMode, notesListMode] = useSettingStore((state) => [
state.settings.notebooksListMode,
state.settings.notesListMode
]);
if (
dataType !== "note" &&
dataType !== "notebook" &&
dataType !== "searchResult"
)
return false;
if (dataType !== "note" && dataType !== "notebook") return false;
const listMode =
dataType === "notebook"
? notebooksListMode
: dataType === "searchResult"
? searchListMode
: notesListMode;
const listMode = dataType === "notebook" ? notebooksListMode : notesListMode;
return listMode === "compact";
}

View File

@@ -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, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { useAsync } from "react-async-hook";
import { Platform } from "react-native";
import Config from "react-native-config";
@@ -181,8 +181,6 @@ 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) => {
@@ -192,11 +190,11 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
);
return (
plansRef.current.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plansRef.current.find((p) => p.id === planId)?.products?.[skuId]
plans.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plans.find((p) => p.id === planId)?.products?.[skuId]
);
},
[webPricingPlans]
[plans, webPricingPlans]
);
const getProductAndroid = (planId: string, skuId: string) => {
@@ -255,41 +253,35 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
useEffect(() => {
const loadPlans = async () => {
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
);
});
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);
});
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) {
/**
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) {
/**
empty */
}
}
setLoadingPlans(false);
} catch (e) {}
}
setLoadingPlans(false);
};
loadPlans();
}, [options?.promoOffer, cancelPromo, hasTrialOffer]);

View File

@@ -32,8 +32,6 @@ const useTimer = (initialId?: string) => {
const [id, setId] = useState(initialId);
const [seconds, setSeconds] = useState(getSecondsLeft(id));
const interval = useRef<NodeJS.Timeout>(undefined);
const secondsRef = useRef(seconds);
secondsRef.current = seconds;
const start = (sec: number, currentId = id) => {
if (!currentId) return;
@@ -61,7 +59,7 @@ const useTimer = (initialId?: string) => {
}
};
return { seconds, setId, start, reset, secondsRef: secondsRef };
return { seconds, setId, start, reset };
};
export default useTimer;

View File

@@ -35,6 +35,7 @@ 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";
@@ -58,11 +59,6 @@ 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"
@@ -117,12 +113,7 @@ 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
);
@@ -141,8 +132,6 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
: null,
[reminder?.id]
);
const [dateError, setDateError] = useState<string>();
const [selectDayError, setSelectDayError] = useState<string>();
const showDatePicker = () => {
setDatePickerVisibility(true);
@@ -154,7 +143,6 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const handleConfirm = (date: Date) => {
timer.current = setTimeout(() => {
setDateError(undefined);
hideDatePicker();
setDate(date);
}, 10);
@@ -184,27 +172,26 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
async function saveReminder() {
try {
if (!formRef.current.validate()) return;
if (date.getTime() < Date.now() && reminderMode === "once") {
setDateError(strings.dateError());
return;
}
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
if (!date && reminderMode !== ReminderModes.Permanent) 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
) {
setSelectDayError(strings.selectDayError());
return;
throw new Error(strings.dateError());
}
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({
@@ -274,10 +261,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
>
<FormInput
name="title"
validators={[validators.required(strings.titleIsRequired())]}
formRef={formRef}
<Input
fwdRef={titleRef}
defaultValue={reminder?.title || referencedItem?.title}
placeholder={strings.remindeMeOf()}
@@ -286,15 +270,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
wrapperStyle={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
onSubmitEditing={() => {
onSubmit={() => {
descriptionRef.current?.focus();
}}
/>
<FormInput
name="description"
validators={[]}
formRef={formRef}
<Input
defaultValue={
reminder ? reminder?.description : referencedItem?.headline
}
@@ -480,22 +461,6 @@ 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}
@@ -511,23 +476,16 @@ 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"}
@@ -561,23 +519,6 @@ 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>
)}

View File

@@ -70,7 +70,6 @@ export const Archive = ({ navigation, route }: NavigationProps<"Archive">) => {
onRefresh={() => {
refresh();
}}
groupType="archive"
renderedInRoute="Archive"
loading={loading}
placeholder={{

View File

@@ -70,7 +70,6 @@ export const Favorites = ({
<List
data={favorites}
dataType="note"
groupType="favorites"
onRefresh={() => {
refresh();
}}

View File

@@ -70,7 +70,6 @@ export const Home = ({ navigation, route }: NavigationProps<"Notes">) => {
<List
data={notes}
dataType="note"
groupType="home"
renderedInRoute={route.name}
loading={loading || !isFocused}
headerTitle={strings.routes[route.name]()}

View File

@@ -185,7 +185,6 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
<List
data={notes}
dataType="note"
groupType="notes"
onRefresh={() => {
onRequestUpdate();
}}

View File

@@ -193,14 +193,11 @@ const NotesPage = ({
hasSearch={true}
id={route.name === "Monographs" ? "Monographs" : params?.current?.id}
onSearch={() => {
if (route.name !== "Monographs" && !item) return;
if (!item) return;
const selector =
route.name === "Monographs"
? db.monographs.all
: item && db.relations.from(item, "note").selector;
if (!selector) return;
: db.relations.from(item, "note").selector;
Navigation.push("Search", {
placeholder: strings.searchInRoute(title || route.name),
@@ -217,7 +214,6 @@ const NotesPage = ({
<List
data={notes}
dataType="note"
groupType="notes"
onRefresh={onRequestUpdate}
loading={false}
renderedInRoute={route.name}

View File

@@ -90,7 +90,6 @@ export const Reminders = ({
<List
data={reminders}
dataType="reminder"
groupType="reminders"
headerTitle={strings.routes[route.name]()}
renderedInRoute="Reminders"
loading={loading}
@@ -99,8 +98,9 @@ export const Reminders = ({
paragraph: strings.remindersEmpty(),
button: strings.setReminder(),
action: async () => {
const reminderFeature =
await isFeatureAvailable("activeReminders");
const reminderFeature = await isFeatureAvailable(
"activeReminders"
);
if (!reminderFeature.isAllowed) {
ToastManager.show({
type: "info",

View File

@@ -154,7 +154,6 @@ export const Search = ({ route, navigation }: NavigationProps<"Search">) => {
data={results}
dataType={route.params?.type}
renderedInRoute={route.name}
groupType="search"
loading={loading}
placeholder={{
title: route.name,

View File

@@ -21,7 +21,6 @@ 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,
@@ -29,13 +28,7 @@ import React, {
useRef,
useState
} from "react";
import {
ActivityIndicator,
Linking,
Platform,
TextInput,
View
} from "react-native";
import { ActivityIndicator, Linking, Platform, 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";
@@ -44,10 +37,7 @@ 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 FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import Input from "../../components/ui/input";
import { Pressable } from "../../components/ui/pressable";
import Seperator from "../../components/ui/seperator";
import { SvgView } from "../../components/ui/svg";
@@ -193,60 +183,27 @@ 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 [generalError, setGeneralError] = useState<string>();
const [error, setError] = useState(false);
useEffect(() => {
if (methodId === "app") {
setLoading(true);
db.mfa
?.setup("app")
.then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
})
.catch((error: Error) => {
setLoading(false);
setGeneralError(error.message);
});
return;
if (method?.id === "app") {
db.mfa?.setup("app").then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
});
}
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]);
}, [method?.id]);
const codeHelpText = {
app: "After putting the above code in authenticator app, the app will display a code that you can enter below.",
@@ -255,43 +212,15 @@ 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 (formRef.current.validateField("code")) return;
if (!code.current || code.current.length !== 6) 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);
await db.mfa.enableFallback(method.id, code.current);
} else {
await db.mfa.enable(method.id, code);
await db.mfa.enable(method.id, code.current);
}
const user = await db.user.fetchUser();
@@ -300,18 +229,14 @@ export const MFASetup = ({
setEnabling(false);
} catch (e) {
const error = e as Error;
formRef.current.setError("code", error.message);
ToastManager.error(error, "Error submitting 2fa code");
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) {
@@ -329,37 +254,30 @@ export const MFASetup = ({
}
try {
const target = formRef.current.getValue("target").trim();
setGeneralError(undefined);
if (seconds) {
setGeneralError(strings.resendCodeWait());
return;
}
if (seconds) throw new Error(strings.resendCodeWait());
if (method.id === "sms" && !phoneNumber.current)
throw new Error(strings.phoneNumberNotEntered());
setSending(true);
await db.mfa.setup(method.id, method.id === "sms" ? target : undefined);
await db.mfa.setup(method?.id, phoneNumber.current);
if (method.id === "sms") {
setId(method.id + target);
setId(method.id + phoneNumber.current);
}
await sleep(300);
start(60, method.id === "sms" ? method.id + target : method.id);
start(
60,
method.id === "sms" ? method.id + phoneNumber.current : 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;
if (method.id === "sms" || method.id === "email") {
formRef.current.setError("target", error.message);
} else {
setGeneralError(error.message);
}
ToastManager.error(error, strings.errorSend2fa());
}
};
@@ -398,54 +316,55 @@ export const MFASetup = ({
</View>
) : (
<>
<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
<Input
loading={method?.id !== "sms" ? true : false}
value={
method?.id === "email"
? user?.email
: method?.id === "app"
? authenticatorDetails?.sharedKey || ""
: undefined
}
multiline={method.id === "app"}
onChangeText={() => {
setGeneralError(undefined);
onChangeText={(value) => {
phoneNumber.current = value;
}}
placeholder={
method.id === "email"
method?.id === "email"
? strings.enterEmailAddress()
: "+1234567890"
}
onSubmitEditing={onSendCode}
validators={targetValidators}
onSubmit={() => {
onSendCode();
}}
onErrorCheck={(e) => setError(e)}
validationType={method?.id === "email" ? "email" : "phonenumber"}
keyboardType={
method.id === "email" ? "email-address" : "phone-pad"
method.id == "email" ? "email-address" : "phone-pad"
}
errorMessage={
method?.id === "email"
? strings.enterValidEmail()
: strings.enterValidPhone()
}
buttons={
<Button
onPress={onSendCode}
loading={sending}
style={{
paddingVertical: 0,
paddingHorizontal: 0
}}
title={
sending
? null
: method.id === "app"
error ? null : (
<Button
onPress={onSendCode}
loading={sending}
title={
sending
? null
: method.id === "app"
? strings.copy()
: `${
seconds
? strings.resendCode(seconds as number)
: strings.sendCode()
}`
}
/>
}
/>
)
}
/>
@@ -454,22 +373,13 @@ export const MFASetup = ({
</Heading>
<Paragraph>{codeHelpText[method?.id]}</Paragraph>
<Seperator />
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
<Input
placeholder="xxxxxx"
maxLength={6}
loading={loading}
textAlign="center"
keyboardType="numeric"
onChangeText={() => {
setGeneralError(undefined);
}}
onSubmitEditing={onNext}
returnKeyLabel={strings.next()}
returnKeyType="done"
validators={codeValidators}
onChangeText={(value) => (code.current = value)}
inputStyle={{
fontSize: AppFontSize.lg,
height: 60,
@@ -482,25 +392,7 @@ 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()}
@@ -625,7 +517,7 @@ export const MFARecoveryCodes = ({
ToastManager.show({
heading: strings.codesCopied(),
type: "success",
context: "local"
context: "global"
});
}}
style={{

View File

@@ -16,20 +16,15 @@ 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 { 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 { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import Input from "../../../components/ui/input";
import { Button } from "../../../components/ui/button";
enum EmailChangeSteps {
verify,
@@ -37,16 +32,14 @@ enum EmailChangeSteps {
}
export const ChangeEmail = () => {
const { colors } = useThemeColors();
const [step, setStep] = useState(EmailChangeSteps.verify);
const formRef = useRef(
createFormRef({
email: "",
password: "",
code: ""
})
);
const emailChangeData = useRef<{
email?: string;
password?: string;
code?: string;
}>({});
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);
@@ -54,52 +47,44 @@ export const ChangeEmail = () => {
const onSubmit = async () => {
try {
if (step === EmailChangeSteps.verify) {
const hasEmailError = formRef.current.validateField("email");
const hasPasswordError = formRef.current.validateField("password");
if (hasEmailError || hasPasswordError) return;
const { email, password } = formRef.current.getValues();
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error
)
return;
setLoading(true);
const verified = await db.user?.verifyPassword(password);
const verified = await db.user?.verifyPassword(
emailChangeData.current.password
);
if (!verified) throw new Error(strings.passwordIncorrect());
await db.user?.sendVerificationEmail(email);
await db.user?.sendVerificationEmail(emailChangeData.current.email);
setStep(EmailChangeSteps.changeEmail);
formRef.current.clearErrors();
formRef.current.setValue("code", "");
setLoading(false);
} else {
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);
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
);
eSendEvent(eUserLoggedIn);
close?.();
ToastManager.show({
heading: strings.emailUpdated(email),
heading: strings.emailUpdated(emailChangeData.current.email),
type: "success",
context: "global"
});
Navigation.goBack();
setLoading(false);
}
} catch (e) {
setLoading(false);
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;
}
ToastManager.error(e as Error);
}
};
@@ -112,54 +97,34 @@ export const ChangeEmail = () => {
>
{step === EmailChangeSteps.verify ? (
<>
<FormInput
name="email"
formRef={formRef}
<Input
fwdRef={emailInputRef}
placeholder={strings.enterNewEmail()}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]}
onSubmitEditing={() => {
passInputRef.current?.focus();
validationType="email"
onErrorCheck={(e) => setError(e)}
onChangeText={(email) => {
emailChangeData.current.email = email;
}}
/>
<FormInput
name="password"
formRef={formRef}
<Input
fwdRef={passInputRef}
placeholder={strings.enterAccountPassword()}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={onSubmit}
onChangeText={(pass) => {
emailChangeData.current.password = pass;
}}
/>
</>
) : (
<>
<FormInput
name="code"
formRef={formRef}
<Input
key="code-input"
fwdRef={codeInputRef}
placeholder={strings.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}
defaultValue=""
onChangeText={(code) => {
emailChangeData.current.code = code;
}}
/>
</>
)}
@@ -170,8 +135,8 @@ export const ChangeEmail = () => {
loading
? undefined
: step === EmailChangeSteps.verify
? strings.verify()
: strings.changeEmail()
? strings.verify()
: strings.changeEmail()
}
type="accent"
style={{

View File

@@ -47,14 +47,6 @@ 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) {
@@ -103,14 +95,6 @@ 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({

View File

@@ -31,10 +31,9 @@ import dayjs from "dayjs";
import React from "react";
import { Appearance, Linking, Platform } from "react-native";
import { getVersion } from "react-native-device-info";
import { TextInput } from "react-native-gesture-handler";
import * as RNIap from "react-native-iap";
import ScreenGuardModule from "react-native-screenguard";
import { DatabaseLogger, db } from "../../common/database";
import { MMKV } from "../../common/database/mmkv";
import filesystem from "../../common/filesystem";
import { presentDialog } from "../../components/dialog/functions";
import { AppLockPassword } from "../../components/dialogs/applock-password";
@@ -43,45 +42,43 @@ import ExportNotesSheet from "../../components/sheets/export-notes";
import { Issue } from "../../components/sheets/github/issue";
import { Progress } from "../../components/sheets/progress";
import { Update } from "../../components/sheets/update";
import {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { VaultStatusType, useVaultStatus } from "../../hooks/use-vault-status";
import { BackgroundSync } from "../../services/background-sync";
import BackupService from "../../services/backup";
import BiometricService from "../../services/biometrics";
import {
ToastManager,
VaultRequestType,
eSendEvent,
eSubscribeEvent,
openVault,
presentSheet
presentSheet,
VaultRequestType
} from "../../services/event-manager";
import Navigation from "../../services/navigation";
import Notifications from "../../services/notifications";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import Sync from "../../services/sync";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
import { useThemeStore } from "../../stores/use-theme-store";
import { useUserStore } from "../../stores/use-user-store";
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
import {
eAfterSync,
eCloseSheet,
eOpenRecoveryKeyDialog
} from "../../utils/events";
import { NotesnookModule } from "../../utils/notesnook-module";
import { sleep } from "../../utils/time";
import { resetTabStore } from "../editor/tiptap/use-tab-store";
import { MFARecoveryCodes, MFASheet } from "./2fa";
import { useDragState } from "./editor/state";
import { verifyUser, verifyUserWithApplock } from "./functions";
import { logoutUser } from "./logout";
import { SettingSection } from "./types";
import { getTimeLeft } from "./user-section";
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
import { MMKV } from "../../common/database/mmkv";
import { resetTabStore } from "../editor/tiptap/use-tab-store";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
export const settingsGroups: SettingSection[] = [
{
@@ -237,29 +234,18 @@ export const settingsGroups: SettingSection[] = [
presentDialog({
title: strings.redeemGiftCode(),
paragraph: strings.redeemGiftCodeDesc(),
form: {
formRef: createFormRef({
code: ""
}),
items: [
{
name: "code",
placeholder: strings.code(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.giftCodeRequired())]
}
],
onFormSubmit: async (form) => {
try {
await db.subscriptions.redeemCode(form.getValue("code"));
return true;
} catch (e) {
form.setError("code", (e as Error).message);
return false;
}
}
},
positiveText: strings.redeem()
input: true,
inputPlaceholder: strings.code(),
positiveText: strings.redeem(),
positivePress: async (value) => {
db.subscriptions.redeemCode(value).catch((e) => {
ToastManager.show({
heading: "Error redeeming code",
message: (e as Error).message,
type: "error"
});
});
}
});
}
},
@@ -529,14 +515,6 @@ 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 () => {

View File

@@ -29,10 +29,6 @@ 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";
@@ -201,34 +197,19 @@ const SettingsUserSection = ({ item }) => {
title: strings.setFullName(),
paragraph: strings.setFullNameDesc(),
positiveText: strings.save(),
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")
});
input: true,
inputPlaceholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
positivePress: async (value) => {
db.settings
.setProfile({
fullName: value
})
.then(async () => {
useUserStore.setState({
profile: db.settings.getProfile()
});
return true;
} catch (e) {
form.setError("fullName", e.message);
return false;
}
}
});
}
});
}}

View File

@@ -98,7 +98,6 @@ export const Trash = ({ navigation, route }: NavigationProps<"Trash">) => {
<List
data={trash}
dataType="trash"
groupType="trash"
renderedInRoute="Trash"
loading={!isFocused}
placeholder={PLACEHOLDER_DATA(db.settings.getTrashCleanupInterval())}

View File

@@ -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 { Platform } from "react-native";
import { NativeModules, Platform } from "react-native";
import { MMKV } from "../common/database/mmkv";
import {
SettingStore,
@@ -26,9 +26,9 @@ import {
} from "../stores/use-setting-store";
import { NotesnookModule } from "../utils/notesnook-module";
import { scale, updateSize } from "../utils/size";
import { DatabaseLogger } from "../common/database";
import { useUserStore } from "../stores/use-user-store";
import ScreenGuardModule from "react-native-screenguard";
ScreenGuardModule.initSettings();
function reset() {
const settings = get();
@@ -88,6 +88,7 @@ function migrateAppLock() {
biometricsAuthEnabled: true
});
}
DatabaseLogger.debug("App lock Migrated");
}
function migrateSettings(settings: SettingStore["settings"]) {

View File

@@ -58,7 +58,6 @@ export type Settings = {
appLockMode?: "none" | "background" | "launch";
notebooksListMode?: "normal" | "compact";
notesListMode?: "normal" | "compact";
searchListMode?: "normal" | "compact";
devMode?: boolean;
notifNotes?: boolean;
pitchBlack?: boolean;
@@ -173,7 +172,6 @@ export const defaultSettings: SettingStore["settings"] = {
appLockMode: "none",
notebooksListMode: "normal",
notesListMode: "normal",
searchListMode: "normal",
devMode: false,
notifNotes: false,
pitchBlack: false,

View File

@@ -48,8 +48,7 @@ export const SORT = {
dateCreated: "Date created",
title: "Title",
dueDate: "Due date",
relevance: "Relevance",
dateDeleted: "Date deleted"
relevance: "Relevance"
};
export const itemSkus = [

View File

@@ -65,14 +65,6 @@ 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({

View File

@@ -2235,35 +2235,14 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-screenguard (2.0.0-beta5):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- react-native-screenguard (1.0.0):
- 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)
- SocketRocket
- Yoga
- SDWebImage (~> 5.11.1)
- react-native-share-extension (2.9.5):
- React
- react-native-sodium (1.6.8):
@@ -3118,6 +3097,8 @@ 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
@@ -3592,6 +3573,7 @@ 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`)
@@ -3857,6 +3839,8 @@ 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:
@@ -3958,7 +3942,7 @@ SPEC CHECKSUMS:
react-native-pdf: edc236298f13f1609e42d41e45b8b6ea88ed10f9
react-native-quick-sqlite: 1ed8d3db1e22a8604d006be69f06053382e93bb0
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
react-native-screenguard: 975d4612dce0c348b19b08a48aa23e9b68584d98
react-native-screenguard: 9fc3b4ad5b97783fc0832638fae0dce51272c661
react-native-share-extension: fdc6aaab51591a2d445df239c446aaa3a99658ec
react-native-sodium: 066f76e46c9be13e9260521e3fa994937c4cdab4
react-native-theme-switch-animation: 449d6db7a760f55740505e7403ae8061debc9a7e
@@ -4011,6 +3995,7 @@ SPEC CHECKSUMS:
RNImageCropPicker: 5fd4ceaead64d8c53c787e4e559004f97bc76df7
RNKeychain: ffd0513e676445c637410b47249460cbf56bc9cb
RNNotifee: dea82c9ec44684eeeac9da85deb5eeb8ebe5937f
RNPrivacySnapshot: ccad3a548338c2f526bb7b1789af3fb0618b7d1d
RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee
RNScreens: ffbb0296608eb3560de641a711bbdb663ed1f6b4
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2184
IOS_MARKETING_VERSION = 3.3.24
IOS_CURRENT_PROJECT_VERSION = 2181
IOS_MARKETING_VERSION = 3.3.21
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2184
IOS_MARKETING_VERSION = 3.3.24
IOS_CURRENT_PROJECT_VERSION = 2181
IOS_MARKETING_VERSION = 3.3.21
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Staging iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2184
IOS_MARKETING_VERSION = 3.3.24
IOS_CURRENT_PROJECT_VERSION = 2181
IOS_MARKETING_VERSION = 3.3.21
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
"version": "3.3.23",
"version": "3.3.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
"version": "3.3.23",
"version": "3.3.20",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@notesnook/mobile",
"version": "3.3.24",
"version": "3.3.21",
"private": true,
"license": "GPL-3.0-or-later",
"scripts": {

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/web",
"version": "3.3.20",
"version": "3.3.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.3.20",
"version": "3.3.16",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@notesnook/web",
"description": "Your private note taking space",
"version": "3.3.20",
"version": "3.3.16",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",

View File

@@ -215,10 +215,7 @@ function DesktopAppContents() {
zIndex: 3
}}
>
<NavigationMenu
onExpand={() => navPane.current?.reset(0)}
canExpand={!isTablet}
/>
<NavigationMenu onExpand={() => navPane.current?.reset(0)} />
</Pane>
)}
{isFocusMode ? null : (

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -231,40 +231,15 @@ 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);
@@ -279,13 +254,13 @@ export async function restoreBackupFile(backupFile: File) {
isValid = true;
continue;
}
if (!skipAttachments && entry.name === "attachments/.attachments_key")
if (entry.name === "attachments/.attachments_key")
attachmentsKey = JSON.parse(await entry.text()) as
| SerializedKey
| Cipher<"base64">;
else if (!skipAttachments && entry.name.startsWith("attachments/"))
else if (entry.name.startsWith("attachments/"))
attachments.push(entry);
else if (!entry.name.startsWith("attachments/")) entries.push(entry);
else entries.push(entry);
}
if (!isValid)
console.warn(
@@ -364,8 +339,6 @@ export async function restoreBackupFile(backupFile: File) {
if (error) {
console.error(error);
showToast("error", `${strings.restoreFailed()}: ${error.message}`);
} else {
showToast("success", strings.backupRestored());
}
}
}

View File

@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useMemo } from "react";
import { Box, Button, Flex, Image, Link, Text } from "@theme-ui/components";
import { getRandom, usePromise } from "@notesnook/common";
import Grberk from "../../assets/testimonials/grberk.jpeg";
import Holenstein from "../../assets/testimonials/holenstein.jpg";
import Jason from "../../assets/testimonials/jason.jpg";
import Cameron from "../../assets/testimonials/cameron.jpg";
@@ -28,6 +29,13 @@ import { SettingsDialog } from "../../dialogs/settings";
import { strings } from "@notesnook/intl";
const testimonials = [
{
username: "grberk",
image: Grberk,
name: "Glenn Berkshier",
link: "https://twitter.com/grberk/status/1438955961490751489",
text: "Are you looking for an alternative to @evernote, or just looking for a more secure note taking platform? Take a look at @notesnook and see if it will fit your needs."
},
{
username: "HolensteinDan",
image: Holenstein,

View File

@@ -16,7 +16,6 @@ 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";
@@ -32,7 +31,6 @@ 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 }
@@ -54,24 +52,6 @@ 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={{
@@ -86,7 +66,7 @@ export function DayPicker(props: DayPickerProps) {
<Button
variant="icon"
sx={{ p: 0 }}
{...subtractOffset({ months: 1 }, { disabled: isPrevMonthBeforeMin })}
{...subtractOffset({ months: 1 })}
>
<ChevronLeft />
</Button>
@@ -103,12 +83,9 @@ export function DayPicker(props: DayPickerProps) {
value={month}
onChange={(e) => {
const selectedOption = e.target.selectedOptions[0];
const d = clampDate(
new Date(selectedOption.dataset.date || ""),
minDate,
maxDate
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
e
);
setOffset(d)?.onClick?.(e as any);
}}
>
{months.map((month) => (
@@ -116,7 +93,6 @@ export function DayPicker(props: DayPickerProps) {
key={month.month + year}
value={month.month}
data-date={month.$date.toDateString()}
disabled={month.disabled}
>
{month.month}
</option>
@@ -135,12 +111,9 @@ export function DayPicker(props: DayPickerProps) {
value={year}
onChange={(e) => {
const selectedOption = e.target.selectedOptions[0];
const d = clampDate(
new Date(selectedOption.dataset.date || ""),
minDate,
maxDate
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
e
);
setOffset(d)?.onClick?.(e as any);
}}
>
{years.map((year) => (
@@ -148,23 +121,13 @@ 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 },
{
disabled: isNextMonthBeyondMax
}
)}
>
<Button variant="icon" sx={{ p: 0 }} {...addOffset({ months: 1 })}>
<ChevronRight />
</Button>
</Flex>
@@ -230,24 +193,3 @@ 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());
}

View File

@@ -50,8 +50,7 @@ import { FlexScrollContainer } from "../scroll-container";
import Tiptap, { OnChangeHandler } from "./tiptap";
import Header from "./header";
import { Attachment } from "../icons";
import { AttachmentProgress, insertAttachments } from "./picker";
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
import { attachFiles, AttachmentProgress, insertAttachments } from "./picker";
import { useEditorManager } from "./manager";
import {
saveAttachment,
@@ -618,11 +617,13 @@ export function Editor(props: EditorProps) {
}
const mime = type === "file" ? "*/*" : "image/*";
await insertAttachments(mime, (attachments) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
});
const attachments = await insertAttachments(mime);
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!attachments) return;
for (const attachment of attachments) {
editor?.attachFile(attachment);
}
}}
onGetAttachmentData={async (attachment) => {
logger.debug("Getting attachment data", {
@@ -645,14 +646,10 @@ export function Editor(props: EditorProps) {
return result;
}}
onAttachFiles={async (files) => {
await AttachFilesDialog.show({
files,
onDone: (attachments) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
}
});
const editor = useEditorManager.getState().getEditor(id)?.editor;
const result = await attachFiles(files);
if (!result) return;
result.forEach((attachment) => editor?.attachFile(attachment));
}}
onInsertInternalLink={async (attributes) => {
const link = await NoteLinkingDialog.show({ attributes });
@@ -802,20 +799,17 @@ function DropZone(props: DropZoneProps) {
}}
onDrop={async (e) => {
try {
const { activeEditorId } = useEditorManager.getState();
if (!e.dataTransfer.files?.length || !activeEditorId) return;
const { activeEditorId, getEditor } = useEditorManager.getState();
const editor = getEditor(activeEditorId || "")?.editor;
if (!e.dataTransfer.files?.length || !editor) return;
e.preventDefault();
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));
}
});
const attachments = await attachFiles(
Array.from(e.dataTransfer.files)
);
for (const attachment of attachments || []) {
editor.attachFile(attachment);
}
} catch (e) {
logger.error(e as Error, "Failed to attach file from drag and drop");
showToast("error", strings.failedToAttachFile());

View File

@@ -18,35 +18,86 @@ 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: string,
onDone: (attachments: Attachment[]) => void
): Promise<void> {
export async function insertAttachments(type = "*/*") {
const files = await showFilePicker({
acceptedFileTypes: type || "*/*",
multiple: true
});
if (!files || files.length === 0) return;
if (!files) return;
return await attachFiles(files, type === "*/*");
}
await AttachFilesDialog.show({
files,
skipSpecialImageHandling: type === "*/*",
onDone
});
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;
}
export async function reuploadAttachment(
@@ -58,16 +109,9 @@ export async function reuploadAttachment(
});
if (!selectedFile) return;
if (
!(await checkFeature("fileSize", {
value: selectedFile.size
}))
) {
return;
}
const options: AddAttachmentOptions = {
expectedFileHash,
showProgress: false,
forceWrite: true
};
@@ -80,72 +124,6 @@ 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
@@ -155,6 +133,8 @@ 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",
@@ -178,6 +158,8 @@ 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 {
@@ -189,7 +171,6 @@ async function pickImage(
...dimensions
};
} catch (e) {
console.error(e);
showToast("error", (e as Error).message);
}
}
@@ -209,6 +190,7 @@ export type AttachmentProgress = {
type AddAttachmentOptions = {
expectedFileHash?: string;
showProgress?: boolean;
forceWrite?: boolean;
};
@@ -216,46 +198,80 @@ async function addAttachment(
file: File,
options: AddAttachmentOptions = {}
): Promise<string> {
const { expectedFileHash } = options;
const { expectedFileHash, showProgress = true } = options;
let forceWrite = options.forceWrite;
const reader = file.stream().getReader();
const { hash, type: hashType } = await hashStream(reader);
reader.releaseLock();
const action = async () => {
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;
}
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 exists = await db.attachments.attachment(hash);
if (!forceWrite && exists) {
forceWrite = (await getUploadedFileSize(hash)) === 0;
}
const key: SerializedKey = await getEncryptionKey();
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 output = await writeEncryptedFile(file, key, hash);
if (!output) throw new Error("Could not encrypt file.");
const key: SerializedKey = await getEncryptionKey();
await db.attachments.add({
...output,
hash,
hashType,
filename: exists?.filename || file.name,
mimeType: exists?.type || file.type,
key
});
}
const output = await writeEncryptedFile(file, key, hash);
if (!output) throw new Error("Could not encrypt file.");
return hash;
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());
}
});
}
function getImageDimensions(file: File) {

View File

@@ -58,7 +58,6 @@ 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";
@@ -69,7 +68,6 @@ import { showFeatureNotAllowedToast } from "../../common/toasts";
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
import { ConfirmDialog } from "../../dialogs/confirm";
import { strings } from "@notesnook/intl";
import { showToast } from "../../utils/toast";
export type OnChangeHandler = (
content: () => string,
@@ -423,32 +421,13 @@ function TipTap(props: TipTapProps) {
previewAttachment: onPreviewAttachment,
createInternalLink: onInsertInternalLink,
getAttachmentData: onGetAttachmentData,
openLink: async (url, openInNewTab) => {
openLink: (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");
}
};

View File

@@ -229,8 +229,7 @@ import {
mdiArrowUp,
mdiInbox,
mdiConsoleLine,
mdiDeleteSweepOutline,
mdiCloseCircle
mdiDeleteSweepOutline
} from "@mdi/js";
import { useTheme } from "@emotion/react";
import { Theme } from "@notesnook/theme";
@@ -587,4 +586,3 @@ 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);

View File

@@ -228,13 +228,7 @@ const tabs: NavigationTabItem[] = [
}
] as const;
function NavigationMenu({
onExpand,
canExpand
}: {
onExpand?: () => void;
canExpand: boolean;
}) {
function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
const isFocusMode = useAppStore((store) => store.isFocusMode);
const navigationTab = useAppStore((store) => store.navigationTab);
const setNavigationTab = useAppStore((store) => store.setNavigationTab);
@@ -358,7 +352,6 @@ function NavigationMenu({
sx={{ p: 1, bg: "transparent" }}
onClick={onExpand}
title={strings.expandSidebar()}
disabled={!canExpand}
>
<ExpandSidebar size={13} color="icon" />
</Button>

View File

@@ -104,7 +104,6 @@ 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;
@@ -666,11 +665,11 @@ export const noteMenuItems: (
title: strings.setExpiry(),
icon: Destruct.path,
premium: !features.expiringNotes.isAllowed,
onClick: withFeatureCheck(features.expiringNotes, async () => {
onClick: async () => {
await NoteExpiryDateDialog.show({
noteId: note.id
});
})
}
},
{
type: "button",

View File

@@ -60,7 +60,6 @@ function PublishView(props: PublishViewProps) {
const publishNote = useStore((store) => store.publish);
const unpublishNote = useStore((store) => store.unpublish);
const [monograph, setMonograph] = useState(props.monograph);
const [copied, setCopied] = useState(false);
const monographAnalytics = useIsFeatureAvailable("monographAnalytics");
const analytics = usePromise(async () => {
if (!monographAnalytics?.isAllowed || !monograph) return { totalViews: 0 };
@@ -111,15 +110,12 @@ function PublishView(props: PublishViewProps) {
<Button
variant="secondary"
className="copyPublishLink"
sx={{ flexShrink: 0, m: 0, color: copied ? "accent" : "initial" }}
sx={{ flexShrink: 0, m: 0 }}
onClick={() => {
writeText(`${hosts.MONOGRAPH_HOST}/${monograph?.id}`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
disabled={copied}
>
{copied ? strings.copied() : strings.copy()}
{strings.copy()}
</Button>
</Flex>
) : null}

View File

@@ -44,7 +44,7 @@ export const AddNotebookDialog = DialogManager.register(
const onSubmit = useCallback(async () => {
if (!title.current.trim())
return showToast("error", strings.titleIsRequired());
return showToast("error", strings.allFieldsRequired());
const id = await db.notebooks.add({
id: props.notebook?.id,

View File

@@ -1,373 +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, 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>
);
}

View File

@@ -123,7 +123,6 @@ export const ConfirmDialog = DialogManager.register(function ConfirmDialog(
<Text
as="div"
variant="body"
sx={{ overflowWrap: "break-word" }}
dangerouslySetInnerHTML={{ __html: mdToHtml(message) }}
/>
) : null}

View File

@@ -22,14 +22,7 @@ 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,
File,
InternalLink
} from "../components/icons";
import { ArrowRight, Checkmark, Icon, Warn } from "../components/icons";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";
@@ -96,19 +89,7 @@ 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,

View File

@@ -0,0 +1,147 @@
/*
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>
);
}
);

View File

@@ -240,8 +240,7 @@ export const BehaviourSettings: SettingsGroup[] = [
useSettingStore.subscribe((s) => s.autoUpdates, listener),
isHidden: () =>
useSettingStore.getState().isFlatpak ||
useSettingStore.getState().isSnap ||
useSettingStore.getState().isPortable,
useSettingStore.getState().isSnap,
components: [
{
type: "toggle",

View File

@@ -47,7 +47,6 @@ import { FlexScrollContainer } from "../../components/scroll-container";
import { useCallback, useEffect, useRef, useState } from "react";
import {
DropdownSettingComponent,
Section,
SectionGroup,
SectionKeys,
Setting,
@@ -255,15 +254,9 @@ export const SettingsDialog = DialogManager.register(function SettingsDialog(
overflow: "auto"
}}
>
{activeSettings.length > 0 ? (
activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))
) : (
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
{strings.noResultsFound()}
</Text>
)}
{activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))}
</FlexScrollContainer>
</Flex>
</Dialog>
@@ -324,17 +317,12 @@ function SettingsSideBar(props: SettingsSideBarProps) {
SettingsGroups.filter((g) => g.section === route)
);
let groups: SettingsGroup[] = [];
const 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
.toLowerCase()
.includes(query);
const isSectionMatch = group.section.includes(query);
if (isTitleMatch || isSectionMatch) {
groups.push(group);
@@ -359,18 +347,6 @@ 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);
}}
/>
@@ -437,10 +413,7 @@ function SettingsGroupComponent(props: { item: SettingsGroup }) {
};
}, [onStateChange]);
const allHidden = item.settings.every((s) => s.isHidden?.());
const hasComponentHeader = typeof item.header !== "string";
if (item.isHidden?.() || (allHidden && !hasComponentHeader)) return null;
if (item.isHidden?.()) return null;
return (
<Flex
sx={{
@@ -744,22 +717,3 @@ 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;
}

View File

@@ -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 { SettingComponent, SettingsGroup } from "./types";
import { SettingsGroup } from "./types";
import { appVersion } from "../../utils/version";
import { writeText } from "clipboard-polyfill";
import { showToast } from "../../utils/toast";
@@ -50,23 +50,6 @@ 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"
? {
@@ -81,7 +64,15 @@ export const AboutSettings: SettingsGroup[] = [
title: strings.checkForUpdates(),
variant: "secondary"
},
copyVersionButton
{
type: "button",
action: async () => {
await writeText(appVersion.formatted);
showToast("info", strings.copied());
},
title: strings.copy(),
variant: "secondary"
}
];
}
},
@@ -91,8 +82,7 @@ export const AboutSettings: SettingsGroup[] = [
description: strings.releaseTrackDesc(),
isHidden: () =>
useSettingStore.getState().isFlatpak ||
useSettingStore.getState().isSnap ||
useSettingStore.getState().isPortable,
useSettingStore.getState().isSnap,
components: [
{
type: "dropdown",

View File

@@ -49,7 +49,6 @@ 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;
@@ -500,20 +499,12 @@ async function downloadFile(
{ type: "download", hash: filename }
);
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;
const signedUrl = (
await axios.get(url, {
headers,
responseType: "text"
})
).data;
logger.debug("Got attachment signed url", { filename });

View File

@@ -286,8 +286,7 @@ class EditorStore extends BaseStore<EditorStore> {
const clearIds: string[] = [];
for (const session of sessions) {
if (session.type === "new") continue;
if (session.note.id !== item.id && session.note.contentId !== item.id)
continue;
if (session.note.id !== item.id && session.note.contentId !== item.id) continue;
if (isDeleted(item) || isTrashItem(item))
clearIds.push(session.tabId);
// if a note becomes conflicted, reopen the session
@@ -332,13 +331,6 @@ class EditorStore extends BaseStore<EditorStore> {
!item.readonly
)
openSession(session.note.id, { force: true, silent: true });
// if a note is made readonly, reopen the session
else if (
session.type !== "readonly" &&
item.type === "note" &&
item.readonly
)
openSession(session.note.id, { force: true, silent: true });
// update the note in all sessions
else if (item.type === "note") {
updateSession(

View File

@@ -90,7 +90,6 @@ class SettingStore extends BaseStore<SettingStore> {
autoUpdates = false;
isFlatpak = false;
isSnap = false;
isPortable = false;
proxyRules?: string;
isInboxEnabled = false;
@@ -105,7 +104,6 @@ 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(),

View File

@@ -18,37 +18,28 @@ 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 async function showFilePicker({
export function showFilePicker({
acceptedFileTypes,
multiple
}: FilePickerOptions): Promise<File[]> {
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 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));
};
});
return result instanceof Error ? [] : result;
}
export async function readFile(file: File): Promise<string> {

View File

@@ -88,11 +88,7 @@ export class WebExtensionServer implements Server {
}
);
let attachment;
for await (const message of attachFiles([clippedFile], [false])) {
if (message.type === "done") attachment = message.attachment;
else if (message.type === "error") return;
}
const attachment = (await attachFiles([clippedFile]))?.at(0);
if (!attachment) return;
clipContent += h("iframe", [], {

View File

@@ -1,8 +0,0 @@
---
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.

View File

@@ -87,4 +87,3 @@ 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,7 +102,10 @@ export async function* exportNotes(
// case where the user has a notebook named attachments
const attachmentsRoot = pathTree.add("attachments", "underscore");
const pendingAttachments: Map<string, Attachment> = new Map();
const resolvedAttachments: Map<
string,
{ path: string; attachment: Attachment }
> = new Map();
for (const [id] of notePathMap) {
const note = await database.notes.note(id);
@@ -120,7 +123,8 @@ export async function* exportNotes(
unlockVault: options.unlockVault,
format,
attachmentsRoot,
pendingAttachments,
resolvedAttachments,
attachmentTree: pathTree,
resolveInternalLink: (link) => {
const internalLink = parseInternalLink(link);
if (!internalLink) return link;
@@ -152,7 +156,7 @@ export async function* exportNotes(
}
}
for (const [path, attachment] of pendingAttachments) {
for (const { path, attachment } of resolvedAttachments.values()) {
yield <ExportableAttachment>{
type: "attachment",
path,
@@ -178,13 +182,18 @@ export async function* exportNote(
});
const ext = FORMAT_TO_EXT[options.format];
const path = [filename, ext].join(".");
const pendingAttachments: Map<string, Attachment> = new Map();
const resolvedAttachments: Map<
string,
{ path: string; attachment: Attachment }
> = new Map();
const attachmentTree = new PathTree();
try {
const content = await exportContent(note, {
format,
attachmentsRoot,
pendingAttachments,
resolvedAttachments,
attachmentTree,
unlockVault: options.unlockVault
});
if (!content) return false;
@@ -197,7 +206,7 @@ export async function* exportNote(
ctime: new Date(note.dateCreated)
};
for (const [path, attachment] of pendingAttachments) {
for (const { path, attachment } of resolvedAttachments.values()) {
yield <ExportableAttachment>{
type: "attachment",
path,
@@ -225,7 +234,8 @@ export async function exportContent(
// TODO: remove these
attachmentsRoot?: string;
pendingAttachments?: Map<string, Attachment>;
resolvedAttachments?: Map<string, { path: string; attachment: Attachment }>;
attachmentTree?: PathTree;
resolveInternalLink?: ResolveInternalLink;
}
) {
@@ -234,7 +244,8 @@ export async function exportContent(
unlockVault,
resolveInternalLink,
attachmentsRoot,
pendingAttachments,
resolvedAttachments,
attachmentTree,
disableTemplate
} = options;
const rawContent = await database.content.findByNoteId(note.id);
@@ -274,7 +285,7 @@ export async function exportContent(
if (
attachmentsRoot &&
pendingAttachments &&
resolvedAttachments &&
format !== "txt" &&
format !== "pdf"
) {
@@ -286,15 +297,32 @@ export async function exportContent(
const sources: Record<string, string> = {};
for (const attachment of attachments) {
const filename = [attachment.hash, attachment.filename].join("-");
const attachmentPath = join(attachmentsRoot, filename);
const existing = resolvedAttachments.get(attachment.hash);
if (existing) {
sources[attachment.hash] = resolveAttachment(
elements,
attachment,
existing.path,
format
);
continue;
}
const baseFilename = attachment.filename || attachment.hash;
const rawAttachmentPath = join(attachmentsRoot, baseFilename);
const attachmentPath =
attachmentTree?.add(rawAttachmentPath) || rawAttachmentPath;
sources[attachment.hash] = resolveAttachment(
elements,
attachment,
attachmentPath,
format
);
pendingAttachments.set(attachmentPath, attachment);
resolvedAttachments.set(attachment.hash, {
path: attachmentPath,
attachment
});
}
return sources;
});

View File

@@ -273,7 +273,7 @@ export default class Lookup {
);
matches.ids = matches.values.map((c) => c.id);
} else {
const sortedNoteIds = await this.db.notes.exportable
const sortedNoteIds = await this.db.notes.all
.fields(["notes.id"])
.items(matches.ids, sortOptions);
const sorted: Matches = { ids: [], values: [] };

View File

@@ -400,16 +400,6 @@ 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)[] = [];

View File

@@ -404,9 +404,7 @@ class UserManager {
const masterKey = await this.getMasterKey();
if (!masterKey) return;
const dataEncryptionKey = await this.keyManager.get("dataEncryptionKey", {
refetchUser: false
});
const dataEncryptionKey = await this.keyManager.get("dataEncryptionKey");
if (!dataEncryptionKey)
return [
{
@@ -417,10 +415,7 @@ class UserManager {
const keys: { version: KeyVersion; key: SerializedKey }[] = [];
const legacyDataEncryptionKey = await this.keyManager.get(
"legacyDataEncryptionKey",
{
refetchUser: false
}
"legacyDataEncryptionKey"
);
if (legacyDataEncryptionKey)
keys.push({
@@ -553,24 +548,18 @@ class UserManager {
const email = newEmail.toLowerCase();
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;
}
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
);
}
recoverAccount(email: string) {

View File

@@ -139,7 +139,6 @@ function Header({
}): JSX.Element {
const tab = useTabContext();
const editor = editors[tab.id];
const tableOfContents = editorControllers[tab.id]?.getTableOfContents?.();
const insets = useSafeArea();
const openedTabsCount = useTabStore((state) => state.tabs.length);
const [isOpen, setOpen] = useState(false);
@@ -149,8 +148,6 @@ function Header({
state.canGoForward
]);
console.log(tableOfContents?.length);
return (
<div
style={{
@@ -499,29 +496,26 @@ function Header({
</span>
</MenuItem>
{tableOfContents?.length ? (
<MenuItem
value="toc"
<MenuItem
value="toc"
style={{
display: "flex",
gap: 10,
alignItems: "center"
}}
>
<TableOfContentsIcon
size={20 * settings.fontScale}
color="var(--nn_primary_icon)"
/>
<span
style={{
display: "flex",
gap: 10,
alignItems: "center"
color: "var(--nn_primary_paragraph)"
}}
>
<TableOfContentsIcon
size={20 * settings.fontScale}
color="var(--nn_primary_icon)"
/>
<span
style={{
color: "var(--nn_primary_paragraph)"
}}
>
{strings.toc()}
</span>
</MenuItem>
) : null}
{strings.toc()}
</span>
</MenuItem>
<MenuItem
value="scroll-top"
style={{

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Extension } from "@tiptap/core";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import { Decoration, DecorationSet } from "prosemirror-view";
import {
EditorState,
Plugin,
@@ -28,7 +28,6 @@ import {
} from "prosemirror-state";
import { SearchSettings } from "../../toolbar/stores/search-store.js";
import { tiptapKeys } from "@notesnook/common";
import { toggleNodesUnderPos } from "../heading/index.js";
type DispatchFn = (tr: Transaction) => void;
declare module "@tiptap/core" {
@@ -296,11 +295,12 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
)
);
const domNode = this.editor.view.domAtPos(from).node;
scrollIntoView(domNode);
this.storage.selectedIndex = nextIndex;
tr.setMeta("isSearching", true);
tr.setMeta("selectedIndex", nextIndex);
if (dispatch) updateView(state, dispatch);
return true;
},
moveToPreviousResult:
@@ -322,8 +322,10 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
)
);
const domNode = this.editor.view.domAtPos(from).node;
scrollIntoView(domNode);
this.storage.selectedIndex = prevIndex;
tr.setMeta("isSearching", true);
tr.setMeta("selectedIndex", prevIndex);
if (dispatch) updateView(state, dispatch);
@@ -468,90 +470,14 @@ export const SearchReplace = Extension.create<SearchOptions, SearchStorage>({
decorations(state) {
return key.getState(state).results;
}
},
appendTransaction: (transactions, oldState, newState) => {
const isSearchTransaction = transactions.find((t) =>
t.getMeta("isSearching")
);
const selectedResult =
this.storage.results?.[this.storage.selectedIndex];
if (!isSearchTransaction || !selectedResult) return;
const tr = newState.tr;
scrollIntoView(this.editor.view, selectedResult.from);
if (expandCollapsedParents(tr, selectedResult.from)) {
return tr;
}
}
})
];
}
});
function expandCollapsedParents(tr: Transaction, pos: number) {
try {
let changed = false;
const $pos = tr.doc.resolve(pos);
for (let depth = 1; depth <= $pos.depth; depth++) {
const node = $pos.node(depth);
const nodePos = $pos.before(depth);
if (
(node.type.name === "callout" ||
node.type.name === "outlineListItem") &&
node.attrs.collapsed
) {
tr.setNodeAttribute(nodePos, "collapsed", false);
changed = true;
}
// expand collapsed heading that hid this node via hidden attribute
if (node.attrs.hidden) {
const parentNode = $pos.node(depth - 1);
const parentContentStart = depth === 1 ? 0 : $pos.before(depth - 1) + 1;
let collapsedHeadingPos = -1;
let collapsedHeadingLevel = -1;
parentNode.forEach((child, offset) => {
const childAbsPos = parentContentStart + offset;
if (childAbsPos >= nodePos) return;
if (
child.type.name === "heading" &&
child.attrs.collapsed &&
!child.attrs.hidden
) {
collapsedHeadingPos = childAbsPos;
collapsedHeadingLevel = child.attrs.level;
}
});
if (collapsedHeadingPos !== -1) {
tr.setNodeAttribute(collapsedHeadingPos, "collapsed", false);
toggleNodesUnderPos(
tr,
collapsedHeadingPos,
collapsedHeadingLevel,
false
);
changed = true;
}
}
}
if (changed) tr.setMeta("preventSave", true);
return changed;
} catch (e) {
console.error("Error expanding collapsed parents: ", e);
}
}
function scrollIntoView(view: EditorView, pos: number) {
function scrollIntoView(domNode: Node) {
setTimeout(() => {
const domNode = view.domAtPos(pos).node;
if ("scrollIntoView" in domNode) {
(domNode as Element).scrollIntoView({
behavior: "instant",

Some files were not shown because too many files have changed in this diff Show More