mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-31 02:58:34 +02:00
Compare commits
117 Commits
2.4.14-and
...
feature/ar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d7e46342 | ||
|
|
cb9e6578c1 | ||
|
|
17a1475ded | ||
|
|
2cee5a4d89 | ||
|
|
337f88c373 | ||
|
|
53d5073690 | ||
|
|
9cc9660b48 | ||
|
|
e6162b2580 | ||
|
|
abc14d2d1f | ||
|
|
fa11b5fabb | ||
|
|
d904916863 | ||
|
|
0cfc2e1609 | ||
|
|
3742e923c9 | ||
|
|
792a254513 | ||
|
|
17928ba182 | ||
|
|
2da0f164c6 | ||
|
|
4eabe79e7e | ||
|
|
89dd8f5b6c | ||
|
|
b4d3662897 | ||
|
|
675e1e2c6f | ||
|
|
0f7f3bae7e | ||
|
|
c917d95a7b | ||
|
|
3ae9001504 | ||
|
|
cc97b8beed | ||
|
|
954bdebcef | ||
|
|
04b62f4e53 | ||
|
|
a2dcb67b36 | ||
|
|
cc9141c981 | ||
|
|
85484b1762 | ||
|
|
1a0c94896a | ||
|
|
068a99463d | ||
|
|
b413315777 | ||
|
|
7227513783 | ||
|
|
083e4e1150 | ||
|
|
09098410a5 | ||
|
|
2c9f5df530 | ||
|
|
3ad346362b | ||
|
|
e176b1bf76 | ||
|
|
b368232598 | ||
|
|
bcd27e10c1 | ||
|
|
af9b9b7083 | ||
|
|
ea1565048a | ||
|
|
59b081704b | ||
|
|
2761222b36 | ||
|
|
571f15b556 | ||
|
|
283efa6e41 | ||
|
|
b3b1b7470e | ||
|
|
a9e59b17dc | ||
|
|
22d946b677 | ||
|
|
6a748c3f98 | ||
|
|
520296106a | ||
|
|
3b727e6f64 | ||
|
|
b77711a53d | ||
|
|
aebb2d414b | ||
|
|
e0e505371c | ||
|
|
b9040a9358 | ||
|
|
68f10ce308 | ||
|
|
8a475f654f | ||
|
|
1043b7df55 | ||
|
|
8383e09243 | ||
|
|
df58a6cfc4 | ||
|
|
a9df577478 | ||
|
|
e021d93893 | ||
|
|
24e13f488e | ||
|
|
6f88b70937 | ||
|
|
a90f957aa4 | ||
|
|
748953b3eb | ||
|
|
874c892986 | ||
|
|
ab678fa3a9 | ||
|
|
c2351ab2d3 | ||
|
|
3fd3e02256 | ||
|
|
d2fd26abd5 | ||
|
|
dd84b9ca2f | ||
|
|
269f083dce | ||
|
|
e18867d3f1 | ||
|
|
e221882f8e | ||
|
|
153ee4bce3 | ||
|
|
a2ecdab61c | ||
|
|
eb089e6211 | ||
|
|
1981c3a2f8 | ||
|
|
68b3ebe318 | ||
|
|
8a0b6264d2 | ||
|
|
239174c3e7 | ||
|
|
db1503d828 | ||
|
|
7b1cbba9e7 | ||
|
|
2c3c193629 | ||
|
|
899e56e9cd | ||
|
|
9c9582ae12 | ||
|
|
b918667b63 | ||
|
|
878c901e06 | ||
|
|
fdd3897a60 | ||
|
|
c958657b01 | ||
|
|
e7175f48a0 | ||
|
|
4e1862d653 | ||
|
|
1d5ac37222 | ||
|
|
32d027fff6 | ||
|
|
9434993373 | ||
|
|
2bd0f1a304 | ||
|
|
7f873f240e | ||
|
|
a0229729b7 | ||
|
|
dd368a4170 | ||
|
|
6d3e985c9d | ||
|
|
15858db21b | ||
|
|
b69a94a96f | ||
|
|
73b965eb7c | ||
|
|
5738f48360 | ||
|
|
de74b8c2cd | ||
|
|
2d07d79057 | ||
|
|
5f6a38c8b1 | ||
|
|
3496e6bfa9 | ||
|
|
fc5b62ba61 | ||
|
|
9d0a206240 | ||
|
|
ee6bbca18c | ||
|
|
e6d5472514 | ||
|
|
1e5f13087b | ||
|
|
abb89ecbc3 | ||
|
|
aa168bec5e |
@@ -20,6 +20,7 @@ const SCOPES = [
|
||||
"logger",
|
||||
"theme",
|
||||
"core",
|
||||
"fs",
|
||||
"clipper",
|
||||
"config",
|
||||
"ci",
|
||||
|
||||
@@ -31,14 +31,6 @@ import { TipManager } from "./services/tip-manager";
|
||||
import { useUserStore } from "./stores/use-user-store";
|
||||
import { View } from "react-native";
|
||||
import { useState } from "react";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
NetInfo.configure({
|
||||
reachabilityUrl: "https://notesnook.com",
|
||||
reachabilityTest: (response) => {
|
||||
if (!response) return false;
|
||||
return response?.status >= 200 && response?.status < 300;
|
||||
}
|
||||
});
|
||||
|
||||
SettingsService.init();
|
||||
SettingsService.checkOrientation();
|
||||
|
||||
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Platform } from "react-native";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import {
|
||||
decrypt,
|
||||
deriveCryptoKey,
|
||||
|
||||
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { Dimensions, Platform } from "react-native";
|
||||
import ImageResizer from "@bam.tech/react-native-image-resizer";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
/**
|
||||
* Scale down & compress images to screen width
|
||||
* for loading in editor.
|
||||
|
||||
@@ -17,25 +17,169 @@ 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 Sodium from "@ammarahmed/react-native-sodium";
|
||||
import {
|
||||
getFileNameWithExtension,
|
||||
isImage,
|
||||
isDocument
|
||||
} from "@notesnook/core/utils/filename";
|
||||
import React from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import Sodium from "@ammarahmed/react-native-sodium";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import { subscribe, zip } from "react-native-zip-archive";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { ShareComponent } from "../../components/sheets/export-notes/share";
|
||||
import { presentSheet, ToastEvent } from "../../services/event-manager";
|
||||
import { ToastEvent, presentSheet } from "../../services/event-manager";
|
||||
import { useAttachmentStore } from "../../stores/use-attachment-store";
|
||||
import { db } from "../database";
|
||||
import Storage from "../database/storage";
|
||||
import { cacheDir } from "./utils";
|
||||
import { getFileNameWithExtension } from "@notesnook/core/utils/filename";
|
||||
import { cacheDir, copyFileAsync, releasePermissions } from "./utils";
|
||||
|
||||
export const FileDownloadStatus = {
|
||||
Success: 1,
|
||||
Fail: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Download all user's attachments
|
||||
* @returns
|
||||
*/
|
||||
export function downloadAllAttachments() {
|
||||
const attachments = db.attachments.all;
|
||||
return downloadAttachments(attachments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads provided attachments to a .zip file
|
||||
* on user's device.
|
||||
* @param attachments
|
||||
* @param onProgress
|
||||
* @returns
|
||||
*/
|
||||
export async function downloadAttachments(
|
||||
attachments,
|
||||
onProgress,
|
||||
canceled,
|
||||
groupId
|
||||
) {
|
||||
if (!attachments || !attachments.length) return;
|
||||
const result = new Map();
|
||||
|
||||
let outputFolder;
|
||||
if (Platform.OS === "android") {
|
||||
// Ask the user to select a directory to store the file
|
||||
let file = await ScopedStorage.openDocumentTree(true);
|
||||
outputFolder = file.uri;
|
||||
if (!outputFolder) return;
|
||||
} else {
|
||||
outputFolder = await Storage.checkAndCreateDir("/downloads/");
|
||||
}
|
||||
|
||||
// Create the folder to zip;
|
||||
const zipSourceFolder = `${cacheDir}/notesnook-attachments`;
|
||||
const zipOutputFile =
|
||||
Platform.OS === "ios"
|
||||
? `${outputFolder}/notesnook-attachments-${Date.now()}.zip`
|
||||
: `${cacheDir}/notesnook-attachments.zip`;
|
||||
if (await RNFetchBlob.fs.exists(zipSourceFolder))
|
||||
await RNFetchBlob.fs.unlink(zipSourceFolder);
|
||||
await RNFetchBlob.fs.mkdir(zipSourceFolder);
|
||||
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
let attachment = attachments[i];
|
||||
const hash = attachment.metadata.hash;
|
||||
try {
|
||||
if (canceled.current) {
|
||||
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
|
||||
return;
|
||||
}
|
||||
onProgress?.(
|
||||
i + 1 / attachments.length,
|
||||
`Downloading attachments (${i + 1}/${
|
||||
attachments.length
|
||||
})... Please wait`
|
||||
);
|
||||
// Download to cache
|
||||
let uri = await downloadAttachment(hash, false, {
|
||||
silent: true,
|
||||
cache: true,
|
||||
groupId: groupId
|
||||
});
|
||||
if (canceled.current) {
|
||||
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
|
||||
return;
|
||||
}
|
||||
if (!uri) throw new Error("Failed to download file");
|
||||
// Move file to the source folder we will zip eventually and rename the file to it's actual name.
|
||||
const filePath = `${zipSourceFolder}/${attachment.metadata.filename}`;
|
||||
await RNFetchBlob.fs.mv(`${cacheDir}/${uri}`, filePath);
|
||||
result.set(hash, {
|
||||
filename: attachment.metadata.filename,
|
||||
status: FileDownloadStatus.Success
|
||||
});
|
||||
} catch (e) {
|
||||
result.set(hash, {
|
||||
filename: attachment.metadata.filename,
|
||||
status: FileDownloadStatus.Fail,
|
||||
reason: e
|
||||
});
|
||||
console.log("Error downloading attachment", e);
|
||||
}
|
||||
}
|
||||
if (canceled.current) {
|
||||
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
|
||||
return;
|
||||
}
|
||||
if (result?.size) {
|
||||
let sub;
|
||||
try {
|
||||
onProgress?.(0, `Zipping... Please wait`);
|
||||
// If all goes well, zip the notesnook-attachments folder in cache.
|
||||
|
||||
sub = subscribe(({ progress }) => {
|
||||
onProgress(
|
||||
progress,
|
||||
`Saving zip file (${(progress * 100).toFixed(1)}%)... Please wait`
|
||||
);
|
||||
});
|
||||
await zip(zipSourceFolder, zipOutputFile);
|
||||
sub?.remove();
|
||||
onProgress(1, `Saving zip file... Please wait`);
|
||||
if (Platform.OS === "android") {
|
||||
// Move the zip to user selected directory.
|
||||
const file = await ScopedStorage.createFile(
|
||||
outputFolder,
|
||||
`notesnook-attachments-${Date.now()}.zip`,
|
||||
"application/zip"
|
||||
);
|
||||
await copyFileAsync(`file://${zipOutputFile}`, file.uri);
|
||||
}
|
||||
|
||||
onProgress?.(1, `Done`);
|
||||
releasePermissions(outputFolder);
|
||||
} catch (e) {
|
||||
releasePermissions(outputFolder);
|
||||
sub?.remove();
|
||||
console.log("Error zipping attachments", e);
|
||||
}
|
||||
// Remove source & zip file from cache.
|
||||
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
|
||||
if (Platform.OS === "android") {
|
||||
RNFetchBlob.fs.unlink(zipOutputFile).catch(console.log);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default async function downloadAttachment(
|
||||
hash,
|
||||
global = true,
|
||||
options = {
|
||||
silent: false,
|
||||
cache: false
|
||||
cache: false,
|
||||
throwError: false,
|
||||
groupId: undefined
|
||||
}
|
||||
) {
|
||||
let attachment = db.attachments.attachment(hash);
|
||||
@@ -55,8 +199,13 @@ export default async function downloadAttachment(
|
||||
}
|
||||
|
||||
try {
|
||||
await db.fs.downloadFile(
|
||||
console.log(
|
||||
"starting download attachment",
|
||||
attachment.metadata.hash,
|
||||
options.groupId
|
||||
);
|
||||
await db.fs.downloadFile(
|
||||
options.groupId || attachment.metadata.hash,
|
||||
attachment.metadata.hash
|
||||
);
|
||||
if (
|
||||
@@ -99,7 +248,8 @@ export default async function downloadAttachment(
|
||||
|
||||
if (
|
||||
attachment.dateUploaded &&
|
||||
!attachment.metadata?.type?.startsWith("image")
|
||||
!isImage(attachment.metadata?.type) &&
|
||||
!isDocument(attachment.metadata?.type)
|
||||
) {
|
||||
RNFetchBlob.fs
|
||||
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.metadata.hash}`)
|
||||
@@ -131,5 +281,8 @@ export default async function downloadAttachment(
|
||||
.catch(console.log);
|
||||
}
|
||||
useAttachmentStore.getState().remove(attachment.metadata.hash);
|
||||
if (options.throwError) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import hosts from "@notesnook/core/utils/constants";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { ToastEvent } from "../../services/event-manager";
|
||||
import { useAttachmentStore } from "../../stores/use-attachment-store";
|
||||
import { db } from "../database";
|
||||
|
||||
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { Platform } from "react-native";
|
||||
import Sodium from "@ammarahmed/react-native-sodium";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { cacheDir, getRandomId } from "./utils";
|
||||
import { db } from "../database";
|
||||
import { compressToBase64 } from "./compress";
|
||||
|
||||
@@ -17,10 +17,11 @@ 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 RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { useAttachmentStore } from "../../stores/use-attachment-store";
|
||||
import { db } from "../database";
|
||||
import { cacheDir } from "./utils";
|
||||
import { isImage, isDocument } from "@notesnook/core/utils/filename";
|
||||
|
||||
export async function uploadFile(filename, data, cancelToken) {
|
||||
if (!data) return false;
|
||||
@@ -63,7 +64,10 @@ export async function uploadFile(filename, data, cancelToken) {
|
||||
if (result) {
|
||||
let attachment = db.attachments.attachment(filename);
|
||||
if (!attachment) return result;
|
||||
if (!attachment.metadata.type.startsWith("image/")) {
|
||||
if (
|
||||
!isImage(attachment.metadata.type) &&
|
||||
!isDocument(attachment.metadata?.type)
|
||||
) {
|
||||
RNFetchBlob.fs.unlink(`${cacheDir}/${filename}`).catch(console.log);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ 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 RNFetchBlob from "rn-fetch-blob";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import { Platform } from "react-native";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
|
||||
export const cacheDir = RNFetchBlob.fs.dirs.CacheDir;
|
||||
|
||||
@@ -62,3 +64,22 @@ export function cancelable(operation) {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function copyFileAsync(source, dest) {
|
||||
return new Promise((resolve) => {
|
||||
ScopedStorage.copyFile(source, dest, (e, r) => {
|
||||
console.log(e, r);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function releasePermissions(path) {
|
||||
if (Platform.OS === "ios") return;
|
||||
const uris = await ScopedStorage.getPersistedUriPermissions();
|
||||
for (let uri of uris) {
|
||||
if (path.startsWith(uri)) {
|
||||
await ScopedStorage.releasePersistableUriPermission(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,15 +102,21 @@ const Actions = ({ attachment, setAttachments, fwdRef }) => {
|
||||
if (res.failed) {
|
||||
db.attachments.markAsFailed(attachment.id, res.failed);
|
||||
setFailed(res.failed);
|
||||
ToastEvent.show({
|
||||
heading: "File check failed with error: " + res.failed,
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
} else {
|
||||
setFailed(null);
|
||||
db.attachments.markAsFailed(attachment.id, null);
|
||||
ToastEvent.show({
|
||||
heading: "File check passed",
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
}
|
||||
ToastEvent.show({
|
||||
heading: "File check passed",
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
|
||||
setAttachments([...db.attachments.all]);
|
||||
setLoading({
|
||||
name: null
|
||||
|
||||
@@ -22,7 +22,6 @@ import { TouchableOpacity, View } from "react-native";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../common/database";
|
||||
import { useAttachmentProgress } from "../../hooks/use-attachment-progress";
|
||||
import { useAttachmentStore } from "../../stores/use-attachment-store";
|
||||
import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { formatBytes } from "../../utils";
|
||||
import { SIZE } from "../../utils/size";
|
||||
@@ -36,24 +35,27 @@ function getFileExtension(filename) {
|
||||
var ext = /^.+\.([^.]+)$/.exec(filename);
|
||||
return ext == null ? "" : ext[1];
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {any} param0
|
||||
* @returns
|
||||
*/
|
||||
export const AttachmentItem = ({ attachment, encryption, setAttachments }) => {
|
||||
|
||||
export const AttachmentItem = ({
|
||||
attachment,
|
||||
encryption,
|
||||
setAttachments,
|
||||
pressable = true,
|
||||
hideWhenNotDownloading
|
||||
}) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const [currentProgress, setCurrentProgress] = useAttachmentProgress(
|
||||
attachment,
|
||||
encryption
|
||||
);
|
||||
const encryptionProgress = useAttachmentStore(
|
||||
(state) => state.encryptionProgress
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
if (!pressable) return;
|
||||
Actions.present(attachment, setAttachments, attachment.metadata.hash);
|
||||
};
|
||||
return (
|
||||
|
||||
return hideWhenNotDownloading &&
|
||||
(!currentProgress || !currentProgress.value) ? null : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onPress}
|
||||
@@ -116,22 +118,22 @@ export const AttachmentItem = ({ attachment, encryption, setAttachments }) => {
|
||||
{attachment.metadata.filename}
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph color={colors.icon} size={SIZE.xs}>
|
||||
{formatBytes(attachment.length)}{" "}
|
||||
{currentProgress?.type
|
||||
? "(" + currentProgress.type + "ing - tap to cancel)"
|
||||
: ""}
|
||||
</Paragraph>
|
||||
{!hideWhenNotDownloading ? (
|
||||
<Paragraph color={colors.icon} size={SIZE.xs}>
|
||||
{formatBytes(attachment.length)}{" "}
|
||||
{currentProgress?.type
|
||||
? "(" + currentProgress.type + "ing - tap to cancel)"
|
||||
: ""}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{currentProgress ||
|
||||
(encryptionProgress && encryptionProgress !== "0.00") ||
|
||||
encryption ? (
|
||||
{currentProgress ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => {
|
||||
if (encryption) return;
|
||||
if (encryption || !pressable) return;
|
||||
db.fs.cancel(attachment.metadata.hash);
|
||||
setCurrentProgress(null);
|
||||
}}
|
||||
@@ -144,13 +146,7 @@ export const AttachmentItem = ({ attachment, encryption, setAttachments }) => {
|
||||
>
|
||||
<ProgressCircleComponent
|
||||
size={SIZE.xxl}
|
||||
progress={
|
||||
encryptionProgress
|
||||
? encryptionProgress
|
||||
: currentProgress?.value
|
||||
? currentProgress?.value / 100
|
||||
: 0
|
||||
}
|
||||
progress={currentProgress?.value ? currentProgress?.value / 100 : 0}
|
||||
showsText
|
||||
textStyle={{
|
||||
fontSize: 10
|
||||
|
||||
273
apps/mobile/app/components/attachments/download-attachments.js
Normal file
273
apps/mobile/app/components/attachments/download-attachments.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
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, { useRef, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { db } from "../../common/database";
|
||||
import { downloadAttachments } from "../../common/filesystem/download-attachment";
|
||||
import { presentSheet } from "../../services/event-manager";
|
||||
import { Button } from "../ui/button";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { ProgressBarComponent } from "../ui/svg/lazy";
|
||||
import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { FlatList } from "react-native-actions-sheet";
|
||||
import { AttachmentItem } from "./attachment-item";
|
||||
|
||||
const DownloadAttachments = ({ close, attachments, isNote, update }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState({
|
||||
value: 0,
|
||||
statusText: "Download started... Please wait"
|
||||
});
|
||||
const [result, setResult] = useState(new Map());
|
||||
const canceled = useRef(false);
|
||||
const groupId = useRef();
|
||||
|
||||
const onDownload = async () => {
|
||||
update({
|
||||
disableClosing: true
|
||||
});
|
||||
setDownloading(true);
|
||||
canceled.current = false;
|
||||
groupId.current = Date.now().toString();
|
||||
const result = await downloadAttachments(
|
||||
attachments,
|
||||
(progress, statusText) => setProgress({ value: progress, statusText }),
|
||||
canceled,
|
||||
groupId.current
|
||||
);
|
||||
if (canceled.current) return;
|
||||
setResult(result || new Map());
|
||||
setDownloading(false);
|
||||
update({
|
||||
disableClosing: false
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
update({
|
||||
disableClosing: false
|
||||
});
|
||||
canceled.current = true;
|
||||
console.log(groupId.current, "canceling groupId downloads");
|
||||
await db.fs.cancel(groupId.current);
|
||||
setDownloading(false);
|
||||
groupId.current = null;
|
||||
};
|
||||
|
||||
const successResults = () => {
|
||||
const results = [];
|
||||
for (let [key, value] of result.entries()) {
|
||||
if (value.status === 1) results.push(db.attachments.attachment(key));
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const failedResults = () => {
|
||||
const results = [];
|
||||
for (let [key, value] of result.entries()) {
|
||||
if (value.status === 0) results.push(db.attachments.attachment(key));
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
function getResultText() {
|
||||
const downloadedAttachmentsCount =
|
||||
attachments.length - failedResults().length;
|
||||
if (downloadedAttachmentsCount === 0)
|
||||
return "Failed to download all attachments";
|
||||
return `Successfully downloaded ${downloadedAttachmentsCount}/${
|
||||
attachments.length
|
||||
} attachments as a zip file at ${
|
||||
Platform.OS === "android" ? "the selected folder" : "Notesnook/downloads"
|
||||
}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12
|
||||
}}
|
||||
>
|
||||
<Heading>
|
||||
{downloading
|
||||
? "Downloading attachments"
|
||||
: result?.size
|
||||
? "Downloaded attachments"
|
||||
: "Download attachments"}
|
||||
</Heading>
|
||||
|
||||
{downloading ? (
|
||||
<Paragraph
|
||||
style={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
{progress.statusText}
|
||||
</Paragraph>
|
||||
) : result?.size ? (
|
||||
<Paragraph
|
||||
style={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
{getResultText()}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph
|
||||
style={{
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
Are you sure you want to download all attachments
|
||||
{isNote ? " of this note?" : "?"}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{downloading ? (
|
||||
<View
|
||||
style={{
|
||||
width: 200,
|
||||
marginTop: 10
|
||||
}}
|
||||
>
|
||||
<ProgressBarComponent
|
||||
height={5}
|
||||
width={null}
|
||||
animated={true}
|
||||
useNativeDriver
|
||||
progress={progress.value ? progress.value / attachments.length : 0}
|
||||
unfilledColor={colors.nav}
|
||||
color={colors.accent}
|
||||
borderWidth={0}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
width: "100%",
|
||||
minHeight: 60,
|
||||
backgroundColor: colors.nav,
|
||||
borderRadius: 5,
|
||||
marginVertical: 12
|
||||
}}
|
||||
data={downloading ? attachments : []}
|
||||
ListEmptyComponent={
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: 60
|
||||
}}
|
||||
>
|
||||
<Paragraph color={colors.icon}>No downloads in progress.</Paragraph>
|
||||
</View>
|
||||
}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<AttachmentItem
|
||||
attachment={item}
|
||||
setAttachments={() => {}}
|
||||
pressable={false}
|
||||
hideWhenNotDownloading={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{result?.size ? (
|
||||
<Button
|
||||
style={{
|
||||
width: 250,
|
||||
borderRadius: 100,
|
||||
marginTop: 20
|
||||
}}
|
||||
onPress={close}
|
||||
type="accent"
|
||||
title="Done"
|
||||
/>
|
||||
) : !downloading ? (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
marginTop: 20
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 100,
|
||||
marginRight: 5
|
||||
}}
|
||||
onPress={close}
|
||||
type="grayBg"
|
||||
title="No"
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 100,
|
||||
marginLeft: 5
|
||||
}}
|
||||
onPress={onDownload}
|
||||
type="accent"
|
||||
title="Yes"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
style={{
|
||||
width: 250,
|
||||
borderRadius: 100,
|
||||
marginTop: 20
|
||||
}}
|
||||
onPress={cancel}
|
||||
type="error"
|
||||
title="Cancel"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
DownloadAttachments.present = (context, attachments, isNote) => {
|
||||
presentSheet({
|
||||
context: context,
|
||||
component: (ref, close, update) => (
|
||||
<DownloadAttachments
|
||||
close={close}
|
||||
attachments={attachments}
|
||||
isNote={isNote}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
export default DownloadAttachments;
|
||||
@@ -18,44 +18,60 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||
import { FlatList } from "react-native-actions-sheet";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../common/database";
|
||||
import filesystem from "../../common/filesystem";
|
||||
import { presentSheet } from "../../services/event-manager";
|
||||
import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { SIZE } from "../../utils/size";
|
||||
import DialogHeader from "../dialog/dialog-header";
|
||||
import SheetProvider from "../sheet-provider";
|
||||
import { IconButton } from "../ui/icon-button";
|
||||
import Input from "../ui/input";
|
||||
import Seperator from "../ui/seperator";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { AttachmentItem } from "./attachment-item";
|
||||
import { FlatList } from "react-native-actions-sheet";
|
||||
import DownloadAttachments from "./download-attachments";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
isAudio,
|
||||
isDocument,
|
||||
isImage,
|
||||
isVideo
|
||||
} from "@notesnook/core/utils/filename";
|
||||
|
||||
export const AttachmentDialog = ({ data }) => {
|
||||
export const AttachmentDialog = ({ note }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const [note, setNote] = useState(data);
|
||||
|
||||
const [attachments, setAttachments] = useState(
|
||||
data
|
||||
? db.attachments.ofNote(data.id, "all")
|
||||
note
|
||||
? db.attachments.ofNote(note.id, "all")
|
||||
: [...(db.attachments.all || [])]
|
||||
);
|
||||
|
||||
const attachmentSearchValue = useRef();
|
||||
const searchTimer = useRef();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentFilter, setCurrentFilter] = useState("all");
|
||||
|
||||
const onChangeText = (text) => {
|
||||
const attachments = note
|
||||
? db.attachments.ofNote(note.id, "all")
|
||||
: [...(db.attachments.all || [])];
|
||||
|
||||
attachmentSearchValue.current = text;
|
||||
if (
|
||||
!attachmentSearchValue.current ||
|
||||
attachmentSearchValue.current === ""
|
||||
) {
|
||||
setAttachments([...db.attachments.all]);
|
||||
setAttachments([...attachments]);
|
||||
}
|
||||
clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
let results = db.lookup.attachments(
|
||||
db.attachments.all,
|
||||
attachments,
|
||||
attachmentSearchValue.current
|
||||
);
|
||||
if (results.length === 0) return;
|
||||
@@ -67,6 +83,77 @@ export const AttachmentDialog = ({ data }) => {
|
||||
<AttachmentItem setAttachments={setAttachments} attachment={item} />
|
||||
);
|
||||
|
||||
const onCheck = async () => {
|
||||
setLoading(true);
|
||||
const checkedAttachments = [];
|
||||
for (let attachment of attachments) {
|
||||
let result = await filesystem.checkAttachment(attachment.metadata.hash);
|
||||
if (result.failed) {
|
||||
await db.attachments.markAsFailed(
|
||||
attachment.metadata.hash,
|
||||
result.failed
|
||||
);
|
||||
} else {
|
||||
await db.attachments.markAsFailed(attachment.id, null);
|
||||
}
|
||||
checkedAttachments.push(
|
||||
db.attachments.attachment(attachment.metadata.hash)
|
||||
);
|
||||
setAttachments([...checkedAttachments]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const attachmentTypes = [
|
||||
{
|
||||
title: "All",
|
||||
filterBy: "all"
|
||||
},
|
||||
{
|
||||
title: "Images",
|
||||
filterBy: "images"
|
||||
},
|
||||
{
|
||||
title: "Documents",
|
||||
filterBy: "documents"
|
||||
},
|
||||
{
|
||||
title: "Video",
|
||||
filterBy: "video"
|
||||
},
|
||||
{
|
||||
title: "Audio",
|
||||
filterBy: "audio"
|
||||
}
|
||||
];
|
||||
|
||||
const filterAttachments = (type) => {
|
||||
const attachments = note
|
||||
? db.attachments.ofNote(note.id, "all")
|
||||
: [...(db.attachments.all || [])];
|
||||
isDocument;
|
||||
switch (type) {
|
||||
case "all":
|
||||
return attachments;
|
||||
case "images":
|
||||
return attachments.filter((attachment) =>
|
||||
isImage(attachment.metadata.type)
|
||||
);
|
||||
case "video":
|
||||
return attachments.filter((attachment) =>
|
||||
isVideo(attachment.metadata.type)
|
||||
);
|
||||
case "audio":
|
||||
return attachments.filter((attachment) =>
|
||||
isAudio(attachment.metadata.type)
|
||||
);
|
||||
case "documents":
|
||||
return attachments.filter((attachment) =>
|
||||
isDocument(attachment.metadata.type)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -75,43 +162,71 @@ export const AttachmentDialog = ({ data }) => {
|
||||
paddingHorizontal: 12
|
||||
}}
|
||||
>
|
||||
<DialogHeader
|
||||
title={note ? "Attachments" : "Manage attachments"}
|
||||
paragraph="Tap on an attachment to view properties"
|
||||
button={{
|
||||
title: "Check all",
|
||||
type: "grayAccent",
|
||||
loading: loading,
|
||||
onPress: async () => {
|
||||
setLoading(true);
|
||||
for (let attachment of attachments) {
|
||||
let result = await filesystem.checkAttachment(
|
||||
attachment.metadata.hash
|
||||
<SheetProvider context="attachments-list" />
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Heading>Attachments</Heading>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row"
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
style={{
|
||||
height: 40,
|
||||
width: 40,
|
||||
marginRight: 10
|
||||
}}
|
||||
size={SIZE.lg}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
name="check-all"
|
||||
customStyle={{
|
||||
height: 40,
|
||||
width: 40,
|
||||
marginRight: 10
|
||||
}}
|
||||
color={colors.pri}
|
||||
size={SIZE.lg}
|
||||
onPress={onCheck}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
name="download"
|
||||
customStyle={{
|
||||
height: 40,
|
||||
width: 40
|
||||
}}
|
||||
color={colors.pri}
|
||||
onPress={() => {
|
||||
DownloadAttachments.present(
|
||||
"attachments-list",
|
||||
attachments,
|
||||
!!note
|
||||
);
|
||||
if (result.failed) {
|
||||
db.attachments.markAsFailed(
|
||||
attachment.metadata.hash,
|
||||
result.failed
|
||||
);
|
||||
} else {
|
||||
db.attachments.markAsFailed(attachment.id, null);
|
||||
}
|
||||
setAttachments([...db.attachments.all]);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
size={SIZE.lg}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Seperator />
|
||||
<Input
|
||||
placeholder="Filter attachments by filename, type or hash"
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={() => {
|
||||
onChangeText(attachmentSearchValue.current);
|
||||
}}
|
||||
/>
|
||||
<Seperator />
|
||||
{!note ? (
|
||||
<Input
|
||||
placeholder="Filter attachments by filename, type or hash"
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={() => {
|
||||
onChangeText(attachmentSearchValue.current);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
keyboardDismissMode="none"
|
||||
@@ -119,6 +234,45 @@ export const AttachmentDialog = ({ data }) => {
|
||||
maxToRenderPerBatch={10}
|
||||
initialNumToRender={10}
|
||||
windowSize={5}
|
||||
stickyHeaderIndices={[0]}
|
||||
ListHeaderComponent={
|
||||
<ScrollView
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 50,
|
||||
flexDirection: "row",
|
||||
backgroundColor: colors.bg
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
minWidth: "100%"
|
||||
}}
|
||||
horizontal
|
||||
>
|
||||
{attachmentTypes.map((item) => (
|
||||
<Button
|
||||
type={currentFilter === item.filterBy ? "grayAccent" : "gray"}
|
||||
key={item.title}
|
||||
title={
|
||||
item.title +
|
||||
` (${filterAttachments(item.filterBy)?.length || 0})`
|
||||
}
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
borderBottomWidth: 1,
|
||||
flexGrow: 1,
|
||||
borderBottomColor:
|
||||
currentFilter !== item.filterBy
|
||||
? "transparent"
|
||||
: colors.accent
|
||||
}}
|
||||
onPress={() => {
|
||||
setCurrentFilter(item.filterBy);
|
||||
setAttachments(filterAttachments(item.filterBy));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View
|
||||
style={{
|
||||
@@ -162,6 +316,6 @@ export const AttachmentDialog = ({ data }) => {
|
||||
|
||||
AttachmentDialog.present = (note) => {
|
||||
presentSheet({
|
||||
component: () => <AttachmentDialog data={note} />
|
||||
component: () => <AttachmentDialog note={note} />
|
||||
});
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ import SheetProvider from "../sheet-provider";
|
||||
import RateAppSheet from "../sheets/rate-app";
|
||||
import RecoveryKeySheet from "../sheets/recovery-key";
|
||||
import RestoreDataSheet from "../sheets/restore-data";
|
||||
import PDFPreview from "../dialogs/pdf-preview";
|
||||
|
||||
const DialogProvider = () => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
@@ -60,6 +61,7 @@ const DialogProvider = () => {
|
||||
{loading ? null : <Expiring />}
|
||||
<AnnouncementDialog />
|
||||
<SessionExpired />
|
||||
<PDFPreview />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
324
apps/mobile/app/components/dialogs/pdf-preview/index.js
Normal file
324
apps/mobile/app/components/dialogs/pdf-preview/index.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
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, { useEffect, useRef, useState } from "react";
|
||||
import { Dimensions, TextInput, View } from "react-native";
|
||||
import {
|
||||
addOrientationListener,
|
||||
removeOrientationListener
|
||||
} from "react-native-orientation";
|
||||
import Pdf from "react-native-pdf";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import downloadAttachment from "../../../common/filesystem/download-attachment";
|
||||
import { cacheDir } from "../../../common/filesystem/utils";
|
||||
import { useAttachmentProgress } from "../../../hooks/use-attachment-progress";
|
||||
import useGlobalSafeAreaInsets from "../../../hooks/use-global-safe-area-insets";
|
||||
import {
|
||||
eSubscribeEvent,
|
||||
eUnSubscribeEvent
|
||||
} from "../../../services/event-manager";
|
||||
import { useThemeStore } from "../../../stores/use-theme-store";
|
||||
import { SIZE } from "../../../utils/size";
|
||||
import { Dialog } from "../../dialog";
|
||||
import BaseDialog from "../../dialog/base-dialog";
|
||||
import { presentDialog } from "../../dialog/functions";
|
||||
import SheetProvider from "../../sheet-provider";
|
||||
import { IconButton } from "../../ui/icon-button";
|
||||
import { ProgressBarComponent } from "../../ui/svg/lazy";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { sleep } from "../../../utils/time";
|
||||
import { MMKV } from "../../../common/database/mmkv";
|
||||
|
||||
const WIN_WIDTH = Dimensions.get("window").width;
|
||||
const WIN_HEIGHT = Dimensions.get("window").height;
|
||||
|
||||
const attachmentSnapshotsKey = "___attachmentsnapshots";
|
||||
const usePDFSnapshot = (attachment) => {
|
||||
const snapshots = useRef(MMKV.getMap(attachmentSnapshotsKey) || {});
|
||||
const snapshot = useRef(snapshots[attachment?.id]);
|
||||
|
||||
function saveSnapshot(ss) {
|
||||
if (!attachment) return;
|
||||
snapshots.current[attachment.id] = ss;
|
||||
MMKV.setMap(attachmentSnapshotsKey, snapshots.current);
|
||||
snapshot.current = snapshots.current[attachment.id];
|
||||
}
|
||||
|
||||
return [snapshot, saveSnapshot];
|
||||
};
|
||||
|
||||
const PDFPreview = () => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pdfSource, setPDFSource] = useState();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [width, setWidth] = useState(WIN_WIDTH);
|
||||
const insets = useGlobalSafeAreaInsets();
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const inputRef = useRef();
|
||||
const pdfRef = useRef();
|
||||
const [attachment, setAttachment] = useState(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [progress] = useAttachmentProgress(attachment);
|
||||
const [snapshot, saveSnapshot] = usePDFSnapshot(attachment);
|
||||
const snapshotValue = useRef(snapshot.current);
|
||||
|
||||
useEffect(() => {
|
||||
eSubscribeEvent("PDFPreview", open);
|
||||
return () => {
|
||||
eUnSubscribeEvent("PDFPreview", open);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onOrientationChange = (o) => {
|
||||
if (o.includes("LANDSCAPE")) {
|
||||
setWidth(WIN_HEIGHT);
|
||||
} else {
|
||||
setWidth(WIN_WIDTH);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
addOrientationListener(onOrientationChange);
|
||||
return () => {
|
||||
removeOrientationListener(onOrientationChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const open = async (attachment) => {
|
||||
setVisible(true);
|
||||
setLoading(true);
|
||||
setTimeout(async () => {
|
||||
setAttachment(attachment);
|
||||
let hash = attachment.metadata.hash;
|
||||
if (!hash) return;
|
||||
const uri = await downloadAttachment(hash, false, {
|
||||
silent: true,
|
||||
cache: true
|
||||
});
|
||||
const path = `${cacheDir}/${uri}`;
|
||||
snapshotValue.current = snapshot.current;
|
||||
setPDFSource("file://" + path);
|
||||
setLoading(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setPDFSource(null);
|
||||
setVisible(false);
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
const onError = async (error) => {
|
||||
if (error?.message === "Password required or incorrect password.") {
|
||||
await sleep(300);
|
||||
presentDialog({
|
||||
context: attachment?.metadata?.hash,
|
||||
input: true,
|
||||
inputPlaceholder: "Enter password",
|
||||
positiveText: "Unlock",
|
||||
title: "Decrypt",
|
||||
paragraph: "Please input password to view pdf.",
|
||||
positivePress: (value) => {
|
||||
setTimeout(() => {
|
||||
setPassword(value);
|
||||
});
|
||||
},
|
||||
onClose: () => {
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
visible && (
|
||||
<BaseDialog animation="fade" visible={true} onRequestClose={close}>
|
||||
<SheetProvider context={attachment?.metadata?.hash} />
|
||||
<Dialog context={attachment?.metadata?.hash} />
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "black"
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Animated.View
|
||||
exiting={FadeOut}
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<ProgressBarComponent
|
||||
indeterminate={!progress}
|
||||
color={colors.accent}
|
||||
borderColor="transparent"
|
||||
progress={parseInt(progress?.value || "100") / 100}
|
||||
/>
|
||||
<Paragraph
|
||||
style={{
|
||||
marginTop: 10
|
||||
}}
|
||||
color={colors.light}
|
||||
>
|
||||
Loading {`${progress?.percent ? `(${progress?.percent})` : ""}`}
|
||||
... Please wait
|
||||
</Paragraph>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 50,
|
||||
marginTop: insets.top,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 12,
|
||||
paddingLeft: 6
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row"
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color={colors.light}
|
||||
name="arrow-left"
|
||||
onPress={close}
|
||||
customStyle={{
|
||||
marginRight: 12
|
||||
}}
|
||||
size={SIZE.xxl}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginRight: 12
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
defaultValue={currentPage + ""}
|
||||
style={{
|
||||
color: colors.pri,
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
paddingVertical: 0,
|
||||
height: 25,
|
||||
backgroundColor: colors.nav,
|
||||
width: 40,
|
||||
textAlign: "center",
|
||||
marginRight: 4,
|
||||
borderRadius: 3,
|
||||
fontFamily: "OpenSans-Regular"
|
||||
}}
|
||||
selectTextOnFocus
|
||||
keyboardType="decimal-pad"
|
||||
onSubmitEditing={(event) => {
|
||||
setCurrentPage(event.nativeEvent.text);
|
||||
pdfRef.current?.setPage(parseInt(event.nativeEvent.text));
|
||||
}}
|
||||
blurOnSubmit
|
||||
/>
|
||||
<Paragraph color={colors.light}>/{numPages}</Paragraph>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row"
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color={colors.light}
|
||||
name="download"
|
||||
onPress={() => {
|
||||
downloadAttachment(attachment.metadata.hash, false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{pdfSource ? (
|
||||
<Animated.View
|
||||
style={{
|
||||
flex: 1
|
||||
}}
|
||||
entering={FadeIn}
|
||||
>
|
||||
<Pdf
|
||||
source={{
|
||||
uri: pdfSource
|
||||
}}
|
||||
ref={pdfRef}
|
||||
onLoadComplete={(numberOfPages) => {
|
||||
setNumPages(numberOfPages);
|
||||
}}
|
||||
onPageChanged={(page) => {
|
||||
setCurrentPage(page);
|
||||
inputRef.current?.setNativeProps({
|
||||
text: page + ""
|
||||
});
|
||||
saveSnapshot({
|
||||
currentPage: page,
|
||||
scale: snapshot?.current?.scale
|
||||
});
|
||||
}}
|
||||
// scale={snapshotValue.current?.scale}
|
||||
// onScaleChanged={(scale) => {
|
||||
// saveSnapshot({
|
||||
// currentPage: snapshot?.current?.currentPage,
|
||||
// scale: scale
|
||||
// });
|
||||
// }}
|
||||
page={snapshotValue?.current?.currentPage}
|
||||
password={password}
|
||||
maxScale={6}
|
||||
onError={onError}
|
||||
onPressLink={(uri) => {
|
||||
console.log(`Link pressed: ${uri}`);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
width: width,
|
||||
height: Dimensions.get("window").height
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</BaseDialog>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default PDFPreview;
|
||||
@@ -70,6 +70,7 @@ const Launcher = React.memo(
|
||||
const introCompleted = useSettingStore(
|
||||
(state) => state.settings.introCompleted
|
||||
);
|
||||
const verifying = useRef(false);
|
||||
|
||||
const loadNotes = useCallback(async () => {
|
||||
if (verifyUser) {
|
||||
@@ -206,6 +207,9 @@ const Launcher = React.memo(
|
||||
setVerifyUser(false);
|
||||
enabled(false);
|
||||
password.current = null;
|
||||
setTimeout(() => {
|
||||
verifying.current = false;
|
||||
}, 1);
|
||||
}
|
||||
}, [setVerifyUser]);
|
||||
|
||||
@@ -214,7 +218,10 @@ const Launcher = React.memo(
|
||||
}, [init, verifyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (verifying.current || useUserStore.getState().shouldBlockVerifyUser)
|
||||
return;
|
||||
if (verifyUser && appState === "active") {
|
||||
verifying.current = true;
|
||||
onUnlockBiometrics();
|
||||
}
|
||||
}, [appState, onUnlockBiometrics, verifyUser]);
|
||||
|
||||
@@ -17,10 +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 React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, { FadeInUp, FadeOutUp } from "react-native-reanimated";
|
||||
import { editorState } from "../../screens/editor/tiptap/utils";
|
||||
import useKeyboard from "../../hooks/use-keyboard";
|
||||
import { DDS } from "../../services/device-detection";
|
||||
import {
|
||||
eSendEvent,
|
||||
@@ -31,6 +31,7 @@ import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { getElevation } from "../../utils";
|
||||
import {
|
||||
eCloseActionSheet,
|
||||
eCloseSheet,
|
||||
eOpenPremiumDialog,
|
||||
eShowGetPremium
|
||||
} from "../../utils/events";
|
||||
@@ -39,12 +40,12 @@ import { sleep } from "../../utils/time";
|
||||
import { Button } from "../ui/button";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const PremiumToast = ({ context = "global", offset = 0 }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const timer = useRef();
|
||||
const keyboard = useKeyboard();
|
||||
|
||||
const open = useCallback(
|
||||
(event) => {
|
||||
@@ -72,7 +73,6 @@ export const PremiumToast = ({ context = "global", offset = 0 }) => {
|
||||
useEffect(() => {
|
||||
eSubscribeEvent(eShowGetPremium, open);
|
||||
return () => {
|
||||
clearTimeout(timer.current);
|
||||
eUnSubscribeEvent(eShowGetPremium, open);
|
||||
};
|
||||
}, [open]);
|
||||
@@ -80,9 +80,7 @@ export const PremiumToast = ({ context = "global", offset = 0 }) => {
|
||||
const onPress = async () => {
|
||||
open(null);
|
||||
eSendEvent(eCloseActionSheet);
|
||||
if (editorState().isFocused) {
|
||||
//tiny.call(EditorWebView, tiny.blur);
|
||||
}
|
||||
eSendEvent(eCloseSheet);
|
||||
await sleep(300);
|
||||
eSendEvent(eOpenPremiumDialog);
|
||||
};
|
||||
@@ -102,9 +100,13 @@ export const PremiumToast = ({ context = "global", offset = 0 }) => {
|
||||
flexDirection: "row",
|
||||
alignSelf: "center",
|
||||
justifyContent: "space-between",
|
||||
top: offset,
|
||||
top: offset + keyboard.keyboardHeight,
|
||||
maxWidth: DDS.isLargeTablet() ? 400 : "98%"
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
setMsg(null);
|
||||
clearTimeout(timer.current);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Tags = ({ item, close }) => {
|
||||
>
|
||||
<Button
|
||||
onPress={async () => {
|
||||
ManageTagsSheet.present(item);
|
||||
ManageTagsSheet.present([item]);
|
||||
}}
|
||||
buttonType={{
|
||||
text: colors.accent
|
||||
|
||||
@@ -36,6 +36,7 @@ import MoveNoteSheet from "../sheets/add-to";
|
||||
import ExportNotesSheet from "../sheets/export-notes";
|
||||
import { IconButton } from "../ui/icon-button";
|
||||
import Heading from "../ui/typography/heading";
|
||||
import ManageTagsSheet from "../sheets/manage-tags";
|
||||
|
||||
export const SelectionHeader = React.memo(() => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
@@ -209,6 +210,20 @@ export const SelectionHeader = React.memo(() => {
|
||||
screen === "Notebook" ||
|
||||
screen === "Reminders" ? null : (
|
||||
<>
|
||||
<IconButton
|
||||
onPress={async () => {
|
||||
await sleep(100);
|
||||
ManageTagsSheet.present(selectedItemsList);
|
||||
}}
|
||||
customStyle={{
|
||||
marginLeft: 10
|
||||
}}
|
||||
color={colors.pri}
|
||||
tooltipText="Manage tags"
|
||||
tooltipPosition={4}
|
||||
name="pound"
|
||||
size={SIZE.xl}
|
||||
/>
|
||||
<IconButton
|
||||
onPress={async () => {
|
||||
//setSelectionMode(false);
|
||||
@@ -224,6 +239,7 @@ export const SelectionHeader = React.memo(() => {
|
||||
name="plus"
|
||||
size={SIZE.xl}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
onPress={async () => {
|
||||
ExportNotesSheet.present(selectedItemsList);
|
||||
|
||||
@@ -42,6 +42,7 @@ import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { eSendEvent } from "../../../services/event-manager";
|
||||
import { eCloseSheet } from "../../../utils/events";
|
||||
import { requestInAppReview } from "../../../services/app-review";
|
||||
|
||||
const ExportNotesSheet = ({ notes, update }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
@@ -72,6 +73,7 @@ const ExportNotesSheet = ({ notes, update }) => {
|
||||
update({ disableClosing: false });
|
||||
setComplete(true);
|
||||
setExporting(false);
|
||||
requestInAppReview();
|
||||
};
|
||||
|
||||
const actions = [
|
||||
|
||||
@@ -22,7 +22,7 @@ import { View } from "react-native";
|
||||
import { ScrollView } from "react-native-actions-sheet";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../../common/database";
|
||||
import { presentSheet, ToastEvent } from "../../../services/event-manager";
|
||||
import { ToastEvent, presentSheet } from "../../../services/event-manager";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { useTagStore } from "../../../stores/use-tag-store";
|
||||
import { useThemeStore } from "../../../stores/use-theme-store";
|
||||
@@ -31,9 +31,10 @@ import Input from "../../ui/input";
|
||||
import { PressableButton } from "../../ui/pressable";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { IconButton } from "../../ui/icon-button";
|
||||
const ManageTagsSheet = (props) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const [note, setNote] = useState(props.note);
|
||||
const [notes, setNotes] = useState(props.notes || []);
|
||||
const allTags = useTagStore((state) => state.tags);
|
||||
const [tags, setTags] = useState([]);
|
||||
const [query, setQuery] = useState(null);
|
||||
@@ -42,7 +43,7 @@ const ManageTagsSheet = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
sortTags();
|
||||
}, [allTags, note, query, sortTags]);
|
||||
}, [allTags, notes, query, sortTags]);
|
||||
|
||||
const sortTags = useCallback(() => {
|
||||
let _tags = [...allTags];
|
||||
@@ -51,13 +52,14 @@ const ManageTagsSheet = (props) => {
|
||||
if (query) {
|
||||
_tags = _tags.filter((t) => t.title.startsWith(query));
|
||||
}
|
||||
const tagsMerged = [...notes.map((note) => note.tags || []).flat()];
|
||||
|
||||
if (!note || !note.tags) {
|
||||
if (!tagsMerged || !tagsMerged.length) {
|
||||
setTags(_tags);
|
||||
return;
|
||||
}
|
||||
let noteTags = [];
|
||||
for (let tag of note.tags) {
|
||||
for (let tag of tagsMerged) {
|
||||
let index = _tags.findIndex((t) => t.title === tag);
|
||||
if (index !== -1) {
|
||||
noteTags.push(_tags[index]);
|
||||
@@ -67,7 +69,7 @@ const ManageTagsSheet = (props) => {
|
||||
noteTags = noteTags.sort((a, b) => a.title.localeCompare(b.title));
|
||||
let combinedTags = [...noteTags, ..._tags];
|
||||
setTags(combinedTags);
|
||||
}, [allTags, note, query]);
|
||||
}, [allTags, notes, query]);
|
||||
|
||||
useEffect(() => {
|
||||
useTagStore.getState().setTags();
|
||||
@@ -85,15 +87,23 @@ const ManageTagsSheet = (props) => {
|
||||
}
|
||||
|
||||
let tag = _query;
|
||||
setNote({ ...note, tags: note.tags ? [...note.tags, tag] : [tag] });
|
||||
setNotes(
|
||||
notes.map((note) => ({
|
||||
...note,
|
||||
tags: note.tags ? [...note.tags, tag] : [tag]
|
||||
}))
|
||||
);
|
||||
|
||||
setQuery(null);
|
||||
inputRef.current?.setNativeProps({
|
||||
text: ""
|
||||
});
|
||||
try {
|
||||
await db.notes.note(note.id).tag(tag);
|
||||
for (let note of notes) {
|
||||
await db.notes.note(note.id).tag(tag);
|
||||
}
|
||||
useTagStore.getState().setTags();
|
||||
setNote(db.notes.note(note.id).data);
|
||||
setNotes(notes.map((note) => db.notes.note(note.id).data));
|
||||
} catch (e) {
|
||||
ToastEvent.show({
|
||||
heading: "Cannot add tag",
|
||||
@@ -180,41 +190,52 @@ const ManageTagsSheet = (props) => {
|
||||
) : null}
|
||||
|
||||
{tags.map((item) => (
|
||||
<TagItem key={item.title} tag={item} note={note} setNote={setNote} />
|
||||
<TagItem
|
||||
key={item.title}
|
||||
tag={item}
|
||||
notes={notes}
|
||||
setNotes={setNotes}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
ManageTagsSheet.present = (note) => {
|
||||
ManageTagsSheet.present = (notes) => {
|
||||
presentSheet({
|
||||
component: (ref) => {
|
||||
return <ManageTagsSheet actionSheetRef={ref} note={note} />;
|
||||
return <ManageTagsSheet actionSheetRef={ref} notes={notes} />;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default ManageTagsSheet;
|
||||
|
||||
const TagItem = ({ tag, note, setNote }) => {
|
||||
const TagItem = ({ tag, notes, setNotes }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
|
||||
const someNotesTagged = notes.some(
|
||||
(note) => note.tags?.indexOf(tag.title) !== -1
|
||||
);
|
||||
const allNotesTagged = notes.every(
|
||||
(note) => note.tags?.indexOf(tag.title) !== -1
|
||||
);
|
||||
const onPress = async () => {
|
||||
let prevNote = { ...note };
|
||||
try {
|
||||
if (prevNote.tags.indexOf(tag.title) !== -1) {
|
||||
await db.notes
|
||||
.note(note.id)
|
||||
.untag(prevNote.tags[prevNote.tags.indexOf(tag.title)]);
|
||||
} else {
|
||||
await db.notes.note(note.id).tag(tag.title);
|
||||
for (let note of notes) {
|
||||
try {
|
||||
if (someNotesTagged) {
|
||||
await db.notes
|
||||
.note(note.id)
|
||||
.untag(note.tags[note.tags.indexOf(tag.title)]);
|
||||
} else {
|
||||
await db.notes.note(note.id).tag(tag.title);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
useTagStore.getState().setTags();
|
||||
setNote(db.notes.note(note.id).data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
useTagStore.getState().setTags();
|
||||
setNotes(notes.map((note) => db.notes.note(note.id).data));
|
||||
setTimeout(() => {
|
||||
Navigation.queueRoutesForUpdate();
|
||||
}, 1);
|
||||
@@ -225,39 +246,36 @@ const TagItem = ({ tag, note, setNote }) => {
|
||||
customStyle={{
|
||||
flexDirection: "row",
|
||||
marginVertical: 5,
|
||||
justifyContent: "space-between",
|
||||
padding: 12
|
||||
justifyContent: "flex-start",
|
||||
height: 40
|
||||
}}
|
||||
onPress={onPress}
|
||||
type={
|
||||
note && note.tags.findIndex((t) => t === tag.title) !== -1
|
||||
? "shade"
|
||||
: "grayBg"
|
||||
}
|
||||
type="gray"
|
||||
>
|
||||
<Heading
|
||||
size={SIZE.sm}
|
||||
color={
|
||||
note && note?.tags.findIndex((t) => t === tag.title) !== -1
|
||||
? colors.accent
|
||||
: colors.pri
|
||||
<IconButton
|
||||
size={22}
|
||||
customStyle={{
|
||||
marginRight: 5,
|
||||
width: 23,
|
||||
height: 23
|
||||
}}
|
||||
color={someNotesTagged || allNotesTagged ? colors.accent : colors.icon}
|
||||
testID={
|
||||
allNotesTagged
|
||||
? "check-circle-outline"
|
||||
: someNotesTagged
|
||||
? "minus-circle-outline"
|
||||
: "checkbox-blank-circle-outline"
|
||||
}
|
||||
>
|
||||
{"#" + tag.title}
|
||||
</Heading>
|
||||
<Icon
|
||||
name={
|
||||
note && note?.tags.findIndex((t) => t === tag.title) !== -1
|
||||
? "minus"
|
||||
: "plus"
|
||||
allNotesTagged
|
||||
? "check-circle-outline"
|
||||
: someNotesTagged
|
||||
? "minus-circle-outline"
|
||||
: "checkbox-blank-circle-outline"
|
||||
}
|
||||
color={
|
||||
note && note?.tags.findIndex((t) => t === tag.title) !== -1
|
||||
? colors.accent
|
||||
: colors.accent
|
||||
}
|
||||
size={SIZE.lg}
|
||||
/>
|
||||
<Paragraph size={SIZE.sm}>{"#" + tag.title}</Paragraph>
|
||||
</PressableButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import Input from "../../ui/input";
|
||||
import Seperator from "../../ui/seperator";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { requestInAppReview } from "../../../services/app-review";
|
||||
|
||||
const PublishNoteSheet = ({ note: item, update }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
@@ -66,6 +67,7 @@ const PublishNoteSheet = ({ note: item, update }) => {
|
||||
Navigation.queueRoutesForUpdate();
|
||||
setPublishLoading(false);
|
||||
}
|
||||
requestInAppReview();
|
||||
} catch (e) {
|
||||
ToastEvent.show({
|
||||
heading: "Could not publish note",
|
||||
|
||||
@@ -43,7 +43,7 @@ import Seperator from "../../ui/seperator";
|
||||
import SheetWrapper from "../../ui/sheet";
|
||||
import { QRCode } from "../../ui/svg/lazy";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
|
||||
class RecoveryKeySheet extends React.Component {
|
||||
constructor(props) {
|
||||
|
||||
@@ -44,7 +44,7 @@ import { Button } from "../../ui/button";
|
||||
import Seperator from "../../ui/seperator";
|
||||
import SheetWrapper from "../../ui/sheet";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
|
||||
const RestoreDataSheet = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
@@ -19,4 +19,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { FeatureType } from "./components/sheets/new-feature";
|
||||
|
||||
export const features: FeatureType[] = [];
|
||||
export const features: FeatureType[] = [
|
||||
{
|
||||
title: "Attachments preview",
|
||||
body: "You can now preview PDFs & Images directly inside Notesnook."
|
||||
},
|
||||
{
|
||||
title: "New attachments manager",
|
||||
body: "The new attachments manager makes is much easier to view & interact with your attachments. It also allows you to download all (or some) of your attachments."
|
||||
},
|
||||
{
|
||||
title: "Assign tags to multiple notes",
|
||||
body: "You can now easily tag multiple notes without first opening them."
|
||||
}
|
||||
];
|
||||
|
||||
@@ -520,7 +520,7 @@ export const useActions = ({ close = () => null, item }) => {
|
||||
}
|
||||
|
||||
async function showAttachments() {
|
||||
AttachmentDialog.present();
|
||||
AttachmentDialog.present(item);
|
||||
}
|
||||
|
||||
async function exportNote() {
|
||||
|
||||
@@ -489,7 +489,8 @@ export const useAppEvents = () => {
|
||||
if (
|
||||
SettingsService.get().appLockMode === "background" &&
|
||||
!useSettingStore.getState().requestBiometrics &&
|
||||
!useUserStore.getState().verifyUser
|
||||
!useUserStore.getState().verifyUser &&
|
||||
!useUserStore.getState().shouldBlockVerifyUser
|
||||
) {
|
||||
useUserStore.getState().setVerifyUser(true);
|
||||
if (Platform.OS === "ios") {
|
||||
|
||||
@@ -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 { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAttachmentStore } from "../stores/use-attachment-store";
|
||||
|
||||
type AttachmentProgress = {
|
||||
@@ -41,22 +41,26 @@ export const useAttachmentProgress = (
|
||||
: undefined
|
||||
);
|
||||
|
||||
const attachmentProgress = progress?.[attachment.metadata.hash];
|
||||
if (attachmentProgress) {
|
||||
const type = attachmentProgress.type;
|
||||
const loaded =
|
||||
attachmentProgress.type === "download"
|
||||
? attachmentProgress.recieved
|
||||
: attachmentProgress.sent;
|
||||
const value = loaded / attachmentProgress.total;
|
||||
setCurrentProgress({
|
||||
value: value * 100,
|
||||
percent: (value * 100).toFixed(0) + "%",
|
||||
type: type
|
||||
});
|
||||
} else {
|
||||
setCurrentProgress(undefined);
|
||||
}
|
||||
useEffect(() => {
|
||||
const attachmentProgress = progress?.[attachment?.metadata?.hash];
|
||||
if (attachmentProgress) {
|
||||
const type = attachmentProgress.type;
|
||||
const loaded =
|
||||
attachmentProgress.type === "download"
|
||||
? attachmentProgress.recieved
|
||||
: attachmentProgress.sent;
|
||||
const value = loaded / attachmentProgress.total;
|
||||
setCurrentProgress({
|
||||
value: value * 100,
|
||||
percent: (value * 100).toFixed(0) + "%",
|
||||
type: type
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setCurrentProgress(undefined);
|
||||
}, 300);
|
||||
}
|
||||
}, [attachment, progress]);
|
||||
|
||||
return [currentProgress, setCurrentProgress];
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"validator": "^13.5.2",
|
||||
"zustand": "^3.6.0",
|
||||
"fflate": "^0.7.3",
|
||||
"timeago.js": "4.0.2"
|
||||
"timeago.js": "4.0.2",
|
||||
"react-native-blob-util": "0.17.3"
|
||||
},
|
||||
"sideEffects": false
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@ const Editor = React.memo(
|
||||
renderKey.current =
|
||||
renderKey.current === `editor-0` ? `editor-1` : `editor-0`;
|
||||
editor.setLoading(true);
|
||||
setTimeout(() => editor.setLoading(false), 10);
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,6 +166,7 @@ const Editor = React.memo(
|
||||
injectedJavaScript={`globalThis.sessionId="${editor.sessionId}";`}
|
||||
javaScriptEnabled={true}
|
||||
focusable={true}
|
||||
onContentProcessDidTerminate={onError}
|
||||
setSupportMultipleWindows={false}
|
||||
overScrollMode="never"
|
||||
scrollEnabled={false}
|
||||
|
||||
@@ -101,8 +101,9 @@ const EditorOverlay = ({ editorId = "", editor }) => {
|
||||
setTimeout(() => {
|
||||
if (!loadingState.current.startTime) {
|
||||
translateValue.value = 6000;
|
||||
opacity.value = 0;
|
||||
}
|
||||
}, 1000);
|
||||
}, 3000);
|
||||
eSubscribeEvent("loadingNote" + editorId, load);
|
||||
return () => {
|
||||
clearTimers();
|
||||
|
||||
@@ -22,7 +22,7 @@ import React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import DocumentPicker from "react-native-document-picker";
|
||||
import { launchCamera, launchImageLibrary } from "react-native-image-picker";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { db } from "../../../common/database";
|
||||
import { compressToBase64 } from "../../../common/filesystem/compress";
|
||||
import { AttachmentItem } from "../../../components/attachments/attachment-item";
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import PremiumService from "../../../services/premium";
|
||||
import { eCloseSheet } from "../../../utils/events";
|
||||
import { editorController, editorState } from "./utils";
|
||||
import { isImage } from "@notesnook/core/utils/filename";
|
||||
const FILE_SIZE_LIMIT = 500 * 1024 * 1024;
|
||||
const IMAGE_SIZE_LIMIT = 50 * 1024 * 1024;
|
||||
|
||||
@@ -74,7 +75,7 @@ const file = async (fileOptions) => {
|
||||
mode: "import",
|
||||
allowMultiSelection: false
|
||||
};
|
||||
if (Platform.OS == "ios") {
|
||||
if (Platform.OS === "ios") {
|
||||
options.copyTo = "cachesDirectory";
|
||||
}
|
||||
await db.attachments.generateKey();
|
||||
@@ -87,14 +88,9 @@ const file = async (fileOptions) => {
|
||||
}
|
||||
|
||||
file = file[0];
|
||||
if (file.type.startsWith("image")) {
|
||||
ToastEvent.show({
|
||||
title: "Type not supported",
|
||||
message: "Please add images from gallery or camera picker.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = Platform.OS === "ios" ? file.fileCopyUri : file.uri;
|
||||
|
||||
if (file.size > FILE_SIZE_LIMIT) {
|
||||
ToastEvent.show({
|
||||
title: "File too large",
|
||||
@@ -114,7 +110,6 @@ const file = async (fileOptions) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = Platform.OS === "ios" ? file.fileCopyUri : file.uri;
|
||||
console.log("file uri: ", uri);
|
||||
uri = Platform.OS === "ios" ? santizeUri(uri) : uri;
|
||||
showEncryptionSheet(file);
|
||||
@@ -125,12 +120,24 @@ const file = async (fileOptions) => {
|
||||
if (!(await attachFile(uri, hash, file.type, file.name, fileOptions)))
|
||||
return;
|
||||
if (Platform.OS === "ios") await RNFetchBlob.fs.unlink(uri);
|
||||
editorController.current?.commands.insertAttachment({
|
||||
hash: hash,
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
size: file.size
|
||||
});
|
||||
if (isImage(file.type)) {
|
||||
editorController.current?.commands.insertImage({
|
||||
hash: hash,
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
size: file.size,
|
||||
dataurl: await db.attachments.read(hash, "base64"),
|
||||
title: file.name
|
||||
});
|
||||
} else {
|
||||
editorController.current?.commands.insertAttachment({
|
||||
hash: hash,
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
size: file.size
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
eSendEvent(eCloseSheet);
|
||||
}, 1000);
|
||||
@@ -255,7 +262,7 @@ const handleImageResponse = async (response, options) => {
|
||||
|
||||
editorController.current?.commands.insertImage({
|
||||
hash: hash,
|
||||
type: image.type,
|
||||
mime: image.type,
|
||||
title: fileName,
|
||||
dataurl: b64,
|
||||
size: image.fileSize,
|
||||
|
||||
@@ -349,7 +349,7 @@ export const useEditorEvents = (
|
||||
});
|
||||
return;
|
||||
}
|
||||
ManageTagsSheet.present(editor.note.current);
|
||||
ManageTagsSheet.present([editor.note.current]);
|
||||
break;
|
||||
case EventTypes.tag:
|
||||
if (editorMessage.value) {
|
||||
@@ -398,9 +398,18 @@ export const useEditorEvents = (
|
||||
openLinkInBrowser(editorMessage.value as string);
|
||||
break;
|
||||
|
||||
case EventTypes.previewAttachment:
|
||||
eSendEvent("ImagePreview", editorMessage.value);
|
||||
case EventTypes.previewAttachment: {
|
||||
const hash = (editorMessage.value as Attachment)?.hash;
|
||||
const attachment = db.attachments?.attachment(hash);
|
||||
if (attachment.metadata.type.startsWith("image/")) {
|
||||
eSendEvent("ImagePreview", editorMessage.value);
|
||||
} else {
|
||||
eSendEvent("PDFPreview", attachment);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ export const useEditor = (
|
||||
const lockedSessionId = useRef<string>();
|
||||
|
||||
const postMessage = useCallback(
|
||||
async <T>(type: string, data: T) =>
|
||||
await post(editorRef, sessionIdRef.current, type, data),
|
||||
async <T>(type: string, data: T, waitFor = 300) =>
|
||||
await post(editorRef, sessionIdRef.current, type, data, waitFor),
|
||||
[sessionIdRef]
|
||||
);
|
||||
|
||||
@@ -120,9 +120,13 @@ export const useEditor = (
|
||||
[editorId]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
setLoading(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
setLoading(false);
|
||||
} else {
|
||||
state.current.ready = false;
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
const withTimer = useCallback(
|
||||
(id: string, fn: () => void, duration: number) => {
|
||||
@@ -336,7 +340,6 @@ export const useEditor = (
|
||||
) => {
|
||||
state.current.currentlyEditing = true;
|
||||
const editorState = useEditorStore.getState();
|
||||
|
||||
if (item && item.type === "new") {
|
||||
currentNote.current && (await reset());
|
||||
const nextSessionId = makeSessionId(item as NoteType);
|
||||
@@ -344,7 +347,7 @@ export const useEditor = (
|
||||
sessionIdRef.current = nextSessionId;
|
||||
sessionHistoryId.current = Date.now();
|
||||
await commands.setSessionId(nextSessionId);
|
||||
await commands.focus();
|
||||
if (state.current?.ready) await commands.focus();
|
||||
lastContentChangeTime.current = 0;
|
||||
useEditorStore.getState().setReadonly(false);
|
||||
} else {
|
||||
@@ -358,7 +361,7 @@ export const useEditor = (
|
||||
!currentContent.current?.data ||
|
||||
currentContent.current?.data.length < 50000
|
||||
) {
|
||||
overlay(false);
|
||||
if (state.current.ready) overlay(false);
|
||||
} else {
|
||||
overlay(true);
|
||||
}
|
||||
@@ -372,7 +375,11 @@ export const useEditor = (
|
||||
currentNote.current = item as NoteType;
|
||||
await commands.setStatus(timeConverter(item.dateEdited), "Saved");
|
||||
await postMessage(EditorEvents.title, item.title);
|
||||
await postMessage(EditorEvents.html, currentContent.current?.data);
|
||||
await postMessage(
|
||||
EditorEvents.html,
|
||||
currentContent.current?.data,
|
||||
10000
|
||||
);
|
||||
useEditorStore.getState().setReadonly(item.readonly);
|
||||
await commands.setTags(currentNote.current);
|
||||
commands.setSettings();
|
||||
@@ -578,6 +585,10 @@ export const useEditor = (
|
||||
lastContentChangeTime.current = Date.now();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
state.current.saveCount = 0;
|
||||
}, [sessionId, loading]);
|
||||
|
||||
const onReady = useCallback(async () => {
|
||||
if (!(await isEditorLoaded(editorRef, sessionIdRef.current))) {
|
||||
eSendEvent("webview_reset");
|
||||
@@ -586,30 +597,24 @@ export const useEditor = (
|
||||
}
|
||||
}, [isDefaultEditor, restoreEditorState]);
|
||||
|
||||
useEffect(() => {
|
||||
state.current.saveCount = 0;
|
||||
(async () => {
|
||||
await commands.setSessionId(sessionIdRef.current);
|
||||
if (sessionIdRef.current) {
|
||||
if (!state.current?.ready) return;
|
||||
await onReady();
|
||||
}
|
||||
})();
|
||||
}, [sessionId, loading, commands, onReady]);
|
||||
|
||||
const onLoad = useCallback(async () => {
|
||||
state.current.ready = true;
|
||||
onReady();
|
||||
postMessage(EditorEvents.theme, theme || useThemeStore.getState().colors);
|
||||
commands.setInsets(
|
||||
isDefaultEditor ? insets : { top: 0, left: 0, right: 0, bottom: 0 }
|
||||
);
|
||||
if (currentNote.current) {
|
||||
loadNote({ ...currentNote.current, forced: true });
|
||||
} else {
|
||||
await commands.setPlaceholder(placeholderTip.current);
|
||||
}
|
||||
commands.setSettings();
|
||||
if (currentNote.current) overlay(true);
|
||||
clearTimeout(timers.current["editor:loaded"]);
|
||||
timers.current["editor:loaded"] = setTimeout(async () => {
|
||||
postMessage(EditorEvents.theme, theme || useThemeStore.getState().colors);
|
||||
commands.setInsets(
|
||||
isDefaultEditor ? insets : { top: 0, left: 0, right: 0, bottom: 0 }
|
||||
);
|
||||
await commands.setSessionId(sessionIdRef.current);
|
||||
await onReady();
|
||||
await commands.setSettings();
|
||||
if (currentNote.current) {
|
||||
loadNote({ ...currentNote.current, forced: true });
|
||||
} else {
|
||||
await commands.setPlaceholder(placeholderTip.current);
|
||||
}
|
||||
state.current.ready = true;
|
||||
}, 300);
|
||||
}, [
|
||||
onReady,
|
||||
postMessage,
|
||||
@@ -617,7 +622,8 @@ export const useEditor = (
|
||||
commands,
|
||||
isDefaultEditor,
|
||||
insets,
|
||||
loadNote
|
||||
loadNote,
|
||||
overlay
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -73,7 +73,8 @@ export async function post<T>(
|
||||
ref: RefObject<WebView>,
|
||||
sessionId: string,
|
||||
type: string,
|
||||
value: T | null = null
|
||||
value: T | null = null,
|
||||
waitFor = 300
|
||||
) {
|
||||
if (!sessionId) {
|
||||
console.warn("post called without sessionId of type:", type);
|
||||
@@ -85,7 +86,7 @@ export async function post<T>(
|
||||
sessionId: sessionId
|
||||
};
|
||||
setImmediate(() => ref.current?.postMessage(JSON.stringify(message)));
|
||||
const response = await getResponse(type);
|
||||
const response = await getResponse(type, waitFor);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -97,7 +98,8 @@ type WebviewResponseData = {
|
||||
};
|
||||
|
||||
export const getResponse = async (
|
||||
type: string
|
||||
type: string,
|
||||
waitFor = 300
|
||||
): Promise<WebviewResponseData | false> => {
|
||||
return new Promise((resolve) => {
|
||||
const callback = (data: WebviewResponseData) => {
|
||||
@@ -107,7 +109,7 @@ export const getResponse = async (
|
||||
eSubscribeEvent(type, callback);
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 5000);
|
||||
}, waitFor);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { useSettingStore } from "../../stores/use-setting-store";
|
||||
import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { editorRef } from "../../utils/global-refs";
|
||||
import { ProgressBar } from "./progress";
|
||||
import { editorController, editorState, textInput } from "./tiptap/utils";
|
||||
import { editorController, textInput } from "./tiptap/utils";
|
||||
export const EditorWrapper = ({ width }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const deviceMode = useSettingStore((state) => state.deviceMode);
|
||||
@@ -48,7 +48,6 @@ export const EditorWrapper = ({ width }) => {
|
||||
const keyboard = useKeyboard();
|
||||
|
||||
const onAppStateChanged = async (state) => {
|
||||
if (editorState().movedAway) return;
|
||||
if (state === "active") {
|
||||
editorController.current.onReady();
|
||||
editorController.current.overlay(false);
|
||||
|
||||
@@ -28,7 +28,7 @@ import React, {
|
||||
import { ActivityIndicator, Linking, Platform, View } from "react-native";
|
||||
import { FlatList } from "react-native-gesture-handler";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { db } from "../../common/database";
|
||||
import Storage from "../../common/database/storage";
|
||||
import DialogHeader from "../../components/dialog/dialog-header";
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useThemeStore } from "../../stores/use-theme-store";
|
||||
import { useUserStore } from "../../stores/use-user-store";
|
||||
import { getElevation } from "../../utils";
|
||||
import { SIZE } from "../../utils/size";
|
||||
import { requestInAppReview } from "../../services/app-review";
|
||||
const AppLock = ({ route }) => {
|
||||
const colors = useThemeStore((state) => state.colors);
|
||||
const appLockMode = useSettingStore((state) => state.settings.appLockMode);
|
||||
@@ -151,6 +152,9 @@ const AppLock = ({ route }) => {
|
||||
return;
|
||||
}
|
||||
SettingsService.set({ appLockMode: item.value });
|
||||
if (!welcome) {
|
||||
requestInAppReview();
|
||||
}
|
||||
}}
|
||||
customStyle={{
|
||||
justifyContent: "flex-start",
|
||||
|
||||
@@ -18,12 +18,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import { LogMessage } from "@streetwriters/logger";
|
||||
import { LogMessage } from "@notesnook/logger";
|
||||
import { format, LogLevel, logManager } from "@notesnook/core/logger";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { FlatList, Platform, TouchableOpacity, View } from "react-native";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import Storage from "../../common/database/storage";
|
||||
import { presentDialog } from "../../components/dialog/functions";
|
||||
import { IconButton } from "../../components/ui/icon-button";
|
||||
|
||||
@@ -268,10 +268,10 @@ export const LICENSES = [
|
||||
link: "https://github.com/RocketChat/rn-extensions-share"
|
||||
},
|
||||
{
|
||||
name: "rn-fetch-blob",
|
||||
name: "react-native-blob-util",
|
||||
licenseType: "MIT",
|
||||
author: "Joltup",
|
||||
link: "https://github.com/joltup/rn-fetch-blob"
|
||||
author: "RonRadtke",
|
||||
link: "https://github.com/RonRadtke/react-native-blob-util"
|
||||
},
|
||||
{
|
||||
name: "react-native-gzip",
|
||||
|
||||
47
apps/mobile/app/services/app-review.ts
Normal file
47
apps/mobile/app/services/app-review.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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 InAppReview from "react-native-in-app-review";
|
||||
import { DatabaseLogger } from "../common/database";
|
||||
import { MMKV } from "../common/database/mmkv";
|
||||
import Config from "react-native-config";
|
||||
import { useUserStore } from "../stores/use-user-store";
|
||||
|
||||
const day_ms = 86400000;
|
||||
export function requestInAppReview() {
|
||||
if (Config.GITHUB_RELEASE === "true") return;
|
||||
|
||||
const time = MMKV.getMap<{ timestamp: number }>("requestInAppReview");
|
||||
|
||||
if (time?.timestamp && time?.timestamp + day_ms * 7 > Date.now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (InAppReview.isAvailable()) {
|
||||
useUserStore.getState().setShouldBlockVerifyUser(true);
|
||||
|
||||
InAppReview.RequestInAppReview()
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
DatabaseLogger.error(error);
|
||||
});
|
||||
MMKV.setMap("requestInAppReview", { timestamp: Date.now() });
|
||||
} else {
|
||||
DatabaseLogger.error(new Error("In App Review not available"));
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { Platform } from "react-native";
|
||||
import FileViewer from "react-native-file-viewer";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import Share from "react-native-share";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { presentDialog } from "../components/dialog/functions";
|
||||
import { DatabaseLogger, db } from "../common/database";
|
||||
import storage from "../common/database/storage";
|
||||
|
||||
@@ -22,7 +22,7 @@ import { zipSync } from "fflate";
|
||||
import { Platform } from "react-native";
|
||||
import RNHTMLtoPDF from "react-native-html-to-pdf-lite";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import RNFetchBlob from "rn-fetch-blob";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import { DatabaseLogger, db } from "../common/database/index";
|
||||
import Storage from "../common/database/storage";
|
||||
import { toTXT } from "../utils";
|
||||
|
||||
@@ -45,6 +45,7 @@ import { sleep } from "../utils/time";
|
||||
import { useRelationStore } from "../stores/use-relation-store";
|
||||
import { useReminderStore } from "../stores/use-reminder-store";
|
||||
import { presentDialog } from "../components/dialog/functions";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
|
||||
export type Reminder = {
|
||||
id: string;
|
||||
@@ -193,7 +194,7 @@ const onEvent = async ({ type, detail }: Event) => {
|
||||
case "Hide":
|
||||
unpinQuickNote();
|
||||
break;
|
||||
case "ReplyInput":
|
||||
case "ReplyInput": {
|
||||
displayNotification({
|
||||
title: "Quick note",
|
||||
message: 'Tap on "Take note" to add a note.',
|
||||
@@ -211,9 +212,17 @@ const onEvent = async ({ type, detail }: Event) => {
|
||||
data: `<p>${input} </p>`
|
||||
}
|
||||
});
|
||||
await db.sync(false, false);
|
||||
const status = await NetInfo.fetch();
|
||||
if (status.isInternetReachable) {
|
||||
try {
|
||||
await db.sync(false, false);
|
||||
} catch (e) {
|
||||
console.log(e, (e as Error).stack);
|
||||
}
|
||||
}
|
||||
useNoteStore.getState().setNotes();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ async function getProducts() {
|
||||
}
|
||||
|
||||
function get() {
|
||||
if (__DEV__ || Config.isTesting) return true;
|
||||
if (__DEV__ || Config.isTesting === "true") return true;
|
||||
|
||||
return SUBSCRIPTION_STATUS.BASIC !== premiumStatus;
|
||||
}
|
||||
@@ -154,9 +154,11 @@ const onUserStatusCheck = async (type) => {
|
||||
};
|
||||
break;
|
||||
case CHECK_IDS.notebookAdd:
|
||||
setTimeout(() => {
|
||||
eSendEvent(eOpenPremiumDialog);
|
||||
}, 500);
|
||||
message = {
|
||||
context: "sheet",
|
||||
title: "Get Notesnook Pro",
|
||||
desc: "With Notesnook Pro you can create unlimited notebooks and do so much more! Get it now."
|
||||
};
|
||||
break;
|
||||
case CHECK_IDS.vaultAdd:
|
||||
message = {
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface UserStore extends State {
|
||||
setLastSynced: (lastSynced: string) => void;
|
||||
verifyUser: boolean;
|
||||
setVerifyUser: (verified: boolean) => void;
|
||||
shouldBlockVerifyUser: boolean;
|
||||
setShouldBlockVerifyUser: (shouldBlockVerifyUser: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserStore>((set) => ({
|
||||
@@ -52,5 +54,12 @@ export const useUserStore = create<UserStore>((set) => ({
|
||||
},
|
||||
setLastSynced: (lastSynced) => set({ lastSynced: lastSynced }),
|
||||
setVerifyUser: (verified) => set({ verifyUser: verified }),
|
||||
lastSyncStatus: SyncStatus.Never
|
||||
lastSyncStatus: SyncStatus.Never,
|
||||
shouldBlockVerifyUser: false,
|
||||
setShouldBlockVerifyUser: (shouldBlockVerifyUser) => {
|
||||
set({ shouldBlockVerifyUser });
|
||||
setTimeout(() => {
|
||||
set({ shouldBlockVerifyUser: false });
|
||||
}, 1000);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -164,7 +164,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
multiDexEnabled true
|
||||
versionCode 2045
|
||||
versionCode 2049
|
||||
versionName getNpmVersion()
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
@@ -276,6 +276,13 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
pickFirst 'lib/x86/libc++_shared.so'
|
||||
pickFirst 'lib/x86_64/libc++_shared.so'
|
||||
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
|
||||
pickFirst 'lib/arm64-v8a/libc++_shared.so'
|
||||
}
|
||||
|
||||
// applicationVariants are e.g. debug, release
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
|
||||
@@ -51,8 +51,7 @@
|
||||
android:theme="@style/BootTheme"
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="false"
|
||||
tools:replace="android:supportsRtl"
|
||||
android:usesCleartextTraffic="true">
|
||||
tools:replace="android:supportsRtl">
|
||||
|
||||
<receiver android:exported="false" android:name=".NoteWidget">
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- Sync notes created from share extension & notification quick notes without requiring to launch the app
|
||||
- Fix formatting is lost when sharing notes to other apps
|
||||
- Fix editor would be blank after entering the app from background
|
||||
- Small bug fixes and ux improvements
|
||||
- Preview PDFs & Images directly inside Notesnook."
|
||||
- Improved attachments manager with support for downloading all attachments
|
||||
- Assign tags to multiple notes
|
||||
- Bug fixes and performance improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
@@ -7,6 +7,15 @@ import Config from 'react-native-config';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import appJson from './app.json';
|
||||
import Notifications from "../app/services/notifications";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
NetInfo.configure({
|
||||
reachabilityUrl: "https://notesnook.com",
|
||||
reachabilityTest: (response) => {
|
||||
if (!response) return false;
|
||||
return response?.status >= 200 && response?.status < 300;
|
||||
}
|
||||
});
|
||||
|
||||
Notifications.init();
|
||||
|
||||
const appName = appJson.name;
|
||||
|
||||
@@ -993,7 +993,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2037;
|
||||
CURRENT_PROJECT_VERSION = 2041;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1067,7 +1067,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.4.14;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -1097,7 +1097,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Notesnook/Notesnook.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 2037;
|
||||
CURRENT_PROJECT_VERSION = 2041;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
GCC_GENERATE_DEBUGGING_SYMBOLS = YES;
|
||||
@@ -1170,7 +1170,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.4.14;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -1328,7 +1328,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2037;
|
||||
CURRENT_PROJECT_VERSION = 2041;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1340,7 +1340,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.4.14;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
|
||||
@@ -1370,7 +1370,7 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2037;
|
||||
CURRENT_PROJECT_VERSION = 2041;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1382,7 +1382,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.4.14;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.notewidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -1411,7 +1411,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Make Note/Make Note.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2037;
|
||||
CURRENT_PROJECT_VERSION = 2041;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1485,7 +1485,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.4.14;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
|
||||
@@ -1515,7 +1515,7 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2037;
|
||||
CURRENT_PROJECT_VERSION = 2041;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 53CWBG3QUC;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -1589,7 +1589,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.4.14;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.streetwriters.notesnook.share;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
PODS:
|
||||
- Base64 (1.1.2)
|
||||
- BEMCheckBox (1.4.1)
|
||||
- boost (1.76.0)
|
||||
- callstack-repack (3.0.0):
|
||||
- callstack-repack (3.2.0):
|
||||
- JWTDecode (~> 3.0)
|
||||
- React-Core
|
||||
- SwiftyRSA
|
||||
- DoubleConversion (1.1.6)
|
||||
- FBLazyVector (0.69.7)
|
||||
- FBReactNativeSpec (0.69.7):
|
||||
@@ -15,6 +18,7 @@ PODS:
|
||||
- fmt (6.2.1)
|
||||
- glog (0.3.5)
|
||||
- GZIP (1.3.0)
|
||||
- JWTDecode (3.0.1)
|
||||
- MMKV (1.2.13):
|
||||
- MMKVCore (~> 1.2.13)
|
||||
- MMKVCore (1.2.13)
|
||||
@@ -234,9 +238,11 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-begin-background-task (0.1.0):
|
||||
- React
|
||||
- react-native-config (1.4.11):
|
||||
- react-native-config/App (= 1.4.11)
|
||||
- react-native-config/App (1.4.11):
|
||||
- react-native-blob-util (0.17.3):
|
||||
- React-Core
|
||||
- react-native-config (1.5.1):
|
||||
- react-native-config/App (= 1.5.1)
|
||||
- react-native-config/App (1.5.1):
|
||||
- React-Core
|
||||
- react-native-date-picker (4.2.6):
|
||||
- React-Core
|
||||
@@ -244,7 +250,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-fingerprint-scanner (5.0.0):
|
||||
- React-Core
|
||||
- react-native-get-random-values (1.8.0):
|
||||
- react-native-get-random-values (1.9.0):
|
||||
- React-Core
|
||||
- react-native-gzip (1.0.0):
|
||||
- Base64
|
||||
@@ -256,18 +262,22 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-image-resizer (3.0.5):
|
||||
- React-Core
|
||||
- react-native-in-app-review (4.3.3):
|
||||
- React-Core
|
||||
- react-native-keep-awake (1.1.0):
|
||||
- React-Core
|
||||
- react-native-mmkv-storage (0.8.0):
|
||||
- MMKV (= 1.2.13)
|
||||
- React-Core
|
||||
- react-native-netinfo (9.3.7):
|
||||
- react-native-netinfo (9.3.10):
|
||||
- React-Core
|
||||
- react-native-notification-sounds (0.5.5):
|
||||
- React
|
||||
- react-native-orientation (3.1.3):
|
||||
- React
|
||||
- react-native-safe-area-context (4.4.1):
|
||||
- react-native-pdf (6.6.2):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (4.5.3):
|
||||
- RCT-Folly
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -275,7 +285,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- react-native-sodium (1.3.0):
|
||||
- React
|
||||
- react-native-webview (11.23.1):
|
||||
- react-native-webview (11.26.1):
|
||||
- React-Core
|
||||
- React-perflogger (0.69.7)
|
||||
- React-RCTActionSheet (0.69.7):
|
||||
@@ -345,15 +355,14 @@ PODS:
|
||||
- React-perflogger (= 0.69.7)
|
||||
- rn-extensions-share (2.4.0):
|
||||
- React-Core
|
||||
- rn-fetch-blob (0.12.0):
|
||||
- RNBootSplash (4.7.1):
|
||||
- React-Core
|
||||
- RNBootSplash (4.3.2):
|
||||
- RNCCheckbox (0.5.15):
|
||||
- BEMCheckBox (~> 1.4)
|
||||
- React-Core
|
||||
- RNCCheckbox (0.5.12):
|
||||
- RNCClipboard (1.11.2):
|
||||
- React-Core
|
||||
- RNCClipboard (1.11.1):
|
||||
- React-Core
|
||||
- RNCMaskedView (0.2.8):
|
||||
- RNCMaskedView (0.2.9):
|
||||
- React-Core
|
||||
- RNDateTimePicker (6.6.0):
|
||||
- React-Core
|
||||
@@ -365,7 +374,7 @@ PODS:
|
||||
- React-Core
|
||||
- RNFlashList (1.4.0):
|
||||
- React-Core
|
||||
- RNGestureHandler (2.7.1):
|
||||
- RNGestureHandler (2.10.1):
|
||||
- React-Core
|
||||
- RNIap (7.5.6):
|
||||
- React-Core
|
||||
@@ -405,21 +414,32 @@ PODS:
|
||||
- React-RCTText
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNScreens (3.18.2):
|
||||
- RNScreens (3.20.0):
|
||||
- React-Core
|
||||
- React-RCTImage
|
||||
- RNSecureRandom (1.0.1):
|
||||
- React
|
||||
- RNShare (7.9.1):
|
||||
- React-Core
|
||||
- RNSVG (12.4.4):
|
||||
- RNSVG (12.5.1):
|
||||
- React-Core
|
||||
- RNTooltips (1.0.3):
|
||||
- pop (~> 1.0)
|
||||
- React
|
||||
- SexyTooltip
|
||||
- RNZipArchive (6.0.9):
|
||||
- React-Core
|
||||
- RNZipArchive/Core (= 6.0.9)
|
||||
- SSZipArchive (~> 2.2)
|
||||
- RNZipArchive/Core (6.0.9):
|
||||
- React-Core
|
||||
- SSZipArchive (~> 2.2)
|
||||
- SexyTooltip (1.2.5):
|
||||
- pop (~> 1.0)
|
||||
- SSZipArchive (2.4.3)
|
||||
- SwiftyRSA (1.7.0):
|
||||
- SwiftyRSA/ObjC (= 1.7.0)
|
||||
- SwiftyRSA/ObjC (1.7.0)
|
||||
- toolbar-android (0.2.1):
|
||||
- React
|
||||
- Yoga (1.14.0)
|
||||
@@ -451,6 +471,7 @@ DEPENDENCIES:
|
||||
- react-native-actions-shortcuts (from `../../node_modules/react-native-actions-shortcuts`)
|
||||
- react-native-background-actions (from `../../node_modules/react-native-background-actions`)
|
||||
- react-native-begin-background-task (from `../../node_modules/react-native-begin-background-task`)
|
||||
- react-native-blob-util (from `../../node_modules/react-native-blob-util`)
|
||||
- react-native-config (from `../../node_modules/react-native-config`)
|
||||
- react-native-date-picker (from `../../node_modules/react-native-date-picker`)
|
||||
- react-native-document-picker (from `../../node_modules/react-native-document-picker`)
|
||||
@@ -460,11 +481,13 @@ DEPENDENCIES:
|
||||
- react-native-html-to-pdf-lite (from `../../node_modules/react-native-html-to-pdf-lite`)
|
||||
- react-native-image-picker (from `../../node_modules/react-native-image-picker`)
|
||||
- "react-native-image-resizer (from `../../node_modules/@bam.tech/react-native-image-resizer`)"
|
||||
- react-native-in-app-review (from `../../node_modules/react-native-in-app-review`)
|
||||
- "react-native-keep-awake (from `../../node_modules/@sayem314/react-native-keep-awake`)"
|
||||
- react-native-mmkv-storage (from `../../node_modules/react-native-mmkv-storage`)
|
||||
- "react-native-netinfo (from `../../node_modules/@react-native-community/netinfo`)"
|
||||
- react-native-notification-sounds (from `../../node_modules/react-native-notification-sounds`)
|
||||
- react-native-orientation (from `../../node_modules/react-native-orientation`)
|
||||
- react-native-pdf (from `../../node_modules/react-native-pdf`)
|
||||
- react-native-safe-area-context (from `../../node_modules/react-native-safe-area-context`)
|
||||
- "react-native-sodium (from `../../node_modules/@ammarahmed/react-native-sodium`)"
|
||||
- react-native-webview (from `../../node_modules/react-native-webview`)
|
||||
@@ -481,7 +504,6 @@ DEPENDENCIES:
|
||||
- React-runtimeexecutor (from `../../node_modules/react-native/ReactCommon/runtimeexecutor`)
|
||||
- ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`)
|
||||
- rn-extensions-share (from `../../node_modules/rn-extensions-share`)
|
||||
- rn-fetch-blob (from `../../node_modules/rn-fetch-blob`)
|
||||
- RNBootSplash (from `../../node_modules/react-native-bootsplash`)
|
||||
- "RNCCheckbox (from `../../node_modules/@react-native-community/checkbox`)"
|
||||
- "RNCClipboard (from `../../node_modules/@react-native-clipboard/clipboard`)"
|
||||
@@ -502,6 +524,7 @@ DEPENDENCIES:
|
||||
- RNShare (from `../../node_modules/react-native-share`)
|
||||
- RNSVG (from `../../node_modules/react-native-svg`)
|
||||
- RNTooltips (from `../../node_modules/react-native-tooltips`)
|
||||
- RNZipArchive (from `../../node_modules/react-native-zip-archive`)
|
||||
- SexyTooltip (from `https://github.com/ammarahm-ed/SexyTooltip.git`)
|
||||
- "toolbar-android (from `../../node_modules/@react-native-community/toolbar-android`)"
|
||||
- Yoga (from `../../node_modules/react-native/ReactCommon/yoga`)
|
||||
@@ -509,11 +532,15 @@ DEPENDENCIES:
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Base64
|
||||
- BEMCheckBox
|
||||
- fmt
|
||||
- GZIP
|
||||
- JWTDecode
|
||||
- MMKV
|
||||
- MMKVCore
|
||||
- pop
|
||||
- SSZipArchive
|
||||
- SwiftyRSA
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
boost:
|
||||
@@ -562,6 +589,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/react-native-background-actions"
|
||||
react-native-begin-background-task:
|
||||
:path: "../../node_modules/react-native-begin-background-task"
|
||||
react-native-blob-util:
|
||||
:path: "../../node_modules/react-native-blob-util"
|
||||
react-native-config:
|
||||
:path: "../../node_modules/react-native-config"
|
||||
react-native-date-picker:
|
||||
@@ -580,6 +609,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/react-native-image-picker"
|
||||
react-native-image-resizer:
|
||||
:path: "../../node_modules/@bam.tech/react-native-image-resizer"
|
||||
react-native-in-app-review:
|
||||
:path: "../../node_modules/react-native-in-app-review"
|
||||
react-native-keep-awake:
|
||||
:path: "../../node_modules/@sayem314/react-native-keep-awake"
|
||||
react-native-mmkv-storage:
|
||||
@@ -590,6 +621,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/react-native-notification-sounds"
|
||||
react-native-orientation:
|
||||
:path: "../../node_modules/react-native-orientation"
|
||||
react-native-pdf:
|
||||
:path: "../../node_modules/react-native-pdf"
|
||||
react-native-safe-area-context:
|
||||
:path: "../../node_modules/react-native-safe-area-context"
|
||||
react-native-sodium:
|
||||
@@ -622,8 +655,6 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/react-native/ReactCommon"
|
||||
rn-extensions-share:
|
||||
:path: "../../node_modules/rn-extensions-share"
|
||||
rn-fetch-blob:
|
||||
:path: "../../node_modules/rn-fetch-blob"
|
||||
RNBootSplash:
|
||||
:path: "../../node_modules/react-native-bootsplash"
|
||||
RNCCheckbox:
|
||||
@@ -664,6 +695,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/react-native-svg"
|
||||
RNTooltips:
|
||||
:path: "../../node_modules/react-native-tooltips"
|
||||
RNZipArchive:
|
||||
:path: "../../node_modules/react-native-zip-archive"
|
||||
SexyTooltip:
|
||||
:git: https://github.com/ammarahm-ed/SexyTooltip.git
|
||||
toolbar-android:
|
||||
@@ -678,14 +711,16 @@ CHECKOUT OPTIONS:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Base64: cecfb41a004124895a7bcee567a89bae5a89d49b
|
||||
BEMCheckBox: 5ba6e37ade3d3657b36caecc35c8b75c6c2b1a4e
|
||||
boost: a7c83b31436843459a1961bfd74b96033dc77234
|
||||
callstack-repack: 9e5425dfffeda7ea87b71729c4097141c7d8ce1c
|
||||
callstack-repack: 3e48a96824e0e0411ae1f48749a2ab103aa62a3a
|
||||
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
|
||||
FBLazyVector: 6b7f5692909b4300d50e7359cdefbcd09dd30faa
|
||||
FBReactNativeSpec: f53cf57758c70c6bfba5230b739cd1071e7a6824
|
||||
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
|
||||
glog: 3d02b25ca00c2d456734d0bcff864cbc62f6ae1a
|
||||
GZIP: 416858efbe66b41b206895ac6dfd5493200d95b3
|
||||
JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87
|
||||
MMKV: aac95d817a100479445633f2b3ed8961b4ac5043
|
||||
MMKVCore: 3388952ded307e41b3ed8a05892736a236ed1b8e
|
||||
pop: d582054913807fd11fd50bfe6a539d91c7e1a55a
|
||||
@@ -706,23 +741,26 @@ SPEC CHECKSUMS:
|
||||
react-native-actions-shortcuts: 5d9cf0c9c308333dfcc1e05c3f9afa8c428e2533
|
||||
react-native-background-actions: 2c251c986f23347f9c1722f05fd296938f60edb1
|
||||
react-native-begin-background-task: 3b889e07458afc5822a7277cf9cbc7cd077e39ee
|
||||
react-native-config: bcafda5b4c51491ee1b0e1d0c4e3905bc7b56c1b
|
||||
react-native-blob-util: 99f4d79189252f597fe0d810c57a3733b1b1dea6
|
||||
react-native-config: 86038147314e2e6d10ea9972022aa171e6b1d4d8
|
||||
react-native-date-picker: 93e43b3084cea595b4d68b1405d6d99849663bd6
|
||||
react-native-document-picker: ec07866a30707f23660c0f3ae591d669d3e89096
|
||||
react-native-fingerprint-scanner: be63e626b31fb951780a5fac5328b065a61a3d6e
|
||||
react-native-get-random-values: a6ea6a8a65dc93e96e24a11105b1a9c8cfe1d72a
|
||||
react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
|
||||
react-native-gzip: 02f9968afa759e189f0414d41f8f4a951a86b4f1
|
||||
react-native-html-to-pdf-lite: 21bfb169bf4cbcd7bec9f736975ee1b3f5292d4a
|
||||
react-native-image-picker: 9c8a2687b69300ad9e95cec5d38f35ab9d32467d
|
||||
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
|
||||
react-native-in-app-review: db8bb167a5f238e7ceca5c242d6b36ce8c4404a4
|
||||
react-native-keep-awake: acbee258db16483744910f0da3ace39eb9ab47fd
|
||||
react-native-mmkv-storage: 8ba3c0216a6df283ece11205b442a3e435aec4e5
|
||||
react-native-netinfo: 2517ad504b3d303e90d7a431b0fcaef76d207983
|
||||
react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2
|
||||
react-native-notification-sounds: da78c828fe1bcbb92d8b505d5261890ed315ff39
|
||||
react-native-orientation: f1caf84d65f1a4fd4511a18f2b924e634ad7a628
|
||||
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
|
||||
react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa
|
||||
react-native-safe-area-context: b8979f5eda6ed5903d4dbc885be3846ea3daa753
|
||||
react-native-sodium: 1681828855ec18fa952f4557cd595bf048cf5c32
|
||||
react-native-webview: d33e2db8925d090871ffeb232dfa50cb3a727581
|
||||
react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1
|
||||
React-perflogger: 8e832d4e21fdfa613033c76d58d7e617341e804b
|
||||
React-RCTActionSheet: 9ca778182a9523991bff6381045885b6e808bb73
|
||||
React-RCTAnimation: 9ced26ad20b96e532ac791a8ab92a7b1ce2266b8
|
||||
@@ -736,28 +774,30 @@ SPEC CHECKSUMS:
|
||||
React-runtimeexecutor: 65cd2782a57e1d59a68aa5d504edf94278578e41
|
||||
ReactCommon: 1e783348b9aa73ae68236271df972ba898560a95
|
||||
rn-extensions-share: 3f0ecce20dfbca1f0358deb4ebfb9ee121a6d92a
|
||||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||
RNBootSplash: 5f346163977573d6b2aeba1b25df9d2245c0d73c
|
||||
RNCCheckbox: ed1b4ca295475b41e7251ebae046360a703b6eb5
|
||||
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
|
||||
RNCMaskedView: bc0170f389056201c82a55e242e5d90070e18e5a
|
||||
RNBootSplash: 3f3f7f82efe2addbfe7ddeda20877ff4d579cd81
|
||||
RNCCheckbox: 43bcc6493611468af0e19f19f029dab3da8561c4
|
||||
RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
|
||||
RNCMaskedView: 949696f25ec596bfc697fc88e6f95cf0c79669b6
|
||||
RNDateTimePicker: 818672afa85519722533d017b832ed09539d9ddb
|
||||
RNDeviceInfo: aad3c663b25752a52bf8fce93f2354001dd185aa
|
||||
RNExitApp: c4e052df2568b43bec8a37c7cd61194d4cfee2c3
|
||||
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
|
||||
RNFlashList: 399bf6a0db68f594ad2c86aaff3ea39564f39f8a
|
||||
RNGestureHandler: b7a872907ee289ada902127f2554fa1d2c076122
|
||||
RNGestureHandler: 42ec7c28dd02d540ed6c9159c57a98ff016492dc
|
||||
RNIap: d248609d1b8937e63bd904e865c318e9b1457eff
|
||||
RNKeychain: 840f8e6f13be0576202aefcdffd26a4f54bfe7b5
|
||||
RNNotifee: 2ae3c18196e6f307fa62ae5c8e5305dea03ff147
|
||||
RNPrivacySnapshot: 8eaf571478a353f2e5184f5c803164f22428b023
|
||||
RNReanimated: f1b109fb8341505ace9d7d2eedd150da1686716b
|
||||
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
|
||||
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
|
||||
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
|
||||
RNShare: a5dc3b9c53ddc73e155b8cd9a94c70c91913c43c
|
||||
RNSVG: ecd661f380a07ba690c9c5929c475a44f432d674
|
||||
RNSVG: d7d7bc8229af3842c9cfc3a723c815a52cdd1105
|
||||
RNTooltips: 5424d4bf0b3d441104127943b1115cc7f0616b1f
|
||||
RNZipArchive: 68a0c6db4b1c103f846f1559622050df254a3ade
|
||||
SexyTooltip: 5c9b4dec52bfb317938cb0488efd9da3717bb6fd
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
SwiftyRSA: 8c6dd1ea7db1b8dc4fb517a202f88bb1354bc2c6
|
||||
toolbar-android: 2a73856e98b750d7e71ce4644d3f41cc98211719
|
||||
Yoga: 0b84a956f7393ef1f37f3bb213c516184e4a689d
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@callstack/repack": "^3.0.0",
|
||||
"@callstack/repack": "^3.2.0",
|
||||
"@react-native-clipboard/clipboard": "^1.9.0",
|
||||
"@react-native-community/checkbox": "^0.5.8",
|
||||
"@react-native-community/netinfo": "^9.3.7",
|
||||
@@ -20,7 +20,7 @@
|
||||
"react-native": "0.69.7",
|
||||
"react-native-background-actions": "^2.6.6",
|
||||
"react-native-begin-background-task": "https://github.com/blockfirm/react-native-begin-background-task.git",
|
||||
"react-native-bootsplash": "^4.1.4",
|
||||
"react-native-bootsplash": "4.1.4",
|
||||
"react-native-config": "^1.4.6",
|
||||
"react-native-device-info": "^8.4.1",
|
||||
"react-native-document-picker": "^7.1.1",
|
||||
@@ -46,10 +46,8 @@
|
||||
"@ammarahmed/react-native-sodium": "1.3.0",
|
||||
"react-native-svg": "^12.3.0",
|
||||
"react-native-tooltips": "^1.0.3",
|
||||
"react-native-vector-icons": "^9.0.0",
|
||||
"react-native-webview": "^11.14.1",
|
||||
"rn-extensions-share": "^2.4.0",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"react-native-gzip":"1.0.0",
|
||||
"@shopify/flash-list":"1.4.0",
|
||||
"@ammarahmed/notifee-react-native": "7.4.4",
|
||||
@@ -59,7 +57,12 @@
|
||||
"react-native-notification-sounds": "0.5.5",
|
||||
"@bam.tech/react-native-image-resizer": "3.0.5",
|
||||
"react-native-navigation-bar-color": "2.0.2",
|
||||
"react-native-actions-shortcuts": "^1.0.1"
|
||||
"react-native-actions-shortcuts": "^1.0.1",
|
||||
"react-native-in-app-review": "4.3.3",
|
||||
"react-native-zip-archive": "6.0.9",
|
||||
"react-native-vector-icons": "9.2.0",
|
||||
"react-native-pdf": "6.6.2",
|
||||
"react-native-blob-util": "0.17.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
@@ -98,6 +101,7 @@
|
||||
"react-test-renderer": "18.0.0",
|
||||
"terser-webpack-plugin": "^5.3.5",
|
||||
"ts-jest": "^28.0.7",
|
||||
"webpack": "^5.74.0"
|
||||
"webpack": "^5.74.0",
|
||||
"react-refresh": "0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ if (isGithubRelease) {
|
||||
android:null
|
||||
}
|
||||
}
|
||||
config.dependencies["react-native-in-app-review"] = {
|
||||
platforms: {
|
||||
android: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- Sync notes created from share extension & notification quick notes in background
|
||||
- Fix formatting is lost when sharing notes to other apps
|
||||
- Fix editor would be blank after entering the app from background sometimes
|
||||
- Small bug fixes and UX improvements
|
||||
- Preview PDFs & Images directly inside Notesnook."
|
||||
- Improved attachments manager with support for downloading all attachments
|
||||
- Assign tags to multiple notes
|
||||
- Bug fixes and performance improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
28692
apps/mobile/package-lock.json
generated
28692
apps/mobile/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "2.4.14",
|
||||
"version": "2.5.0",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -25,13 +25,15 @@
|
||||
"devDependencies": {
|
||||
"patch-package": "^6.4.7",
|
||||
"typescript": "^4.8.2",
|
||||
"otplib": "12.0.1"
|
||||
"otplib": "12.0.1",
|
||||
"react-refresh": "0.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.0.0",
|
||||
"react-native": "0.69.7",
|
||||
"@notesnook/core": "*",
|
||||
"@notesnook/editor": "*",
|
||||
"@notesnook/editor-mobile": "*"
|
||||
"@notesnook/editor-mobile": "*",
|
||||
"@notesnook/logger": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
diff --git a/node_modules/rn-fetch-blob/android/build.gradle b/node_modules/rn-fetch-blob/android/build.gradle
|
||||
index a4ca7a4..4fd3cfa 100644
|
||||
--- a/node_modules/rn-fetch-blob/android/build.gradle
|
||||
+++ b/node_modules/rn-fetch-blob/android/build.gradle
|
||||
@@ -41,6 +41,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}"
|
||||
- //compile 'com.squareup.okhttp3:okhttp:+'
|
||||
+ implementation 'com.squareup.okhttp3:okhttp:3.4.1'
|
||||
+
|
||||
//{RNFetchBlob_PRE_0.28_DEPDENDENCY}
|
||||
}
|
||||
diff --git a/node_modules/rn-fetch-blob/react-native.config.js b/node_modules/rn-fetch-blob/react-native.config.js
|
||||
deleted file mode 100644
|
||||
index 03c61b6..0000000
|
||||
--- a/node_modules/rn-fetch-blob/react-native.config.js
|
||||
+++ /dev/null
|
||||
@@ -1,7 +0,0 @@
|
||||
-module.exports = {
|
||||
- dependency: {
|
||||
- hooks: {
|
||||
- prelink: 'node ./node_modules/rn-fetch-blob/scripts/prelink.js',
|
||||
- },
|
||||
- },
|
||||
-};
|
||||
diff --git a/node_modules/rn-fetch-blob/scripts/prelink.js b/node_modules/rn-fetch-blob/scripts/prelink.js
|
||||
deleted file mode 100644
|
||||
index e2c3ac4..0000000
|
||||
--- a/node_modules/rn-fetch-blob/scripts/prelink.js
|
||||
+++ /dev/null
|
||||
@@ -1,71 +0,0 @@
|
||||
-try {
|
||||
- var fs = require('fs');
|
||||
- var glob = require('glob');
|
||||
- var addAndroidPermissions = process.env.RNFB_ANDROID_PERMISSIONS == 'true';
|
||||
- var MANIFEST_PATH = glob.sync(process.cwd() + '/android/app/src/main/**/AndroidManifest.xml')[0];
|
||||
- var PACKAGE_JSON = process.cwd() + '/package.json';
|
||||
- var package = JSON.parse(fs.readFileSync(PACKAGE_JSON));
|
||||
- var APP_NAME = package.name;
|
||||
- var PACKAGE_GRADLE = process.cwd() + '/node_modules/rn-fetch-blob/android/build.gradle'
|
||||
- var VERSION = checkVersion();
|
||||
-
|
||||
- console.log('RNFetchBlob detected app version => ' + VERSION);
|
||||
-
|
||||
- if(VERSION < 0.28) {
|
||||
- console.log('You project version is '+ VERSION + ' which may not compatible to rn-fetch-blob 7.0+, please consider upgrade your application template to react-native 0.27+.')
|
||||
- // add OkHttp3 dependency fo pre 0.28 project
|
||||
- var main = fs.readFileSync(PACKAGE_GRADLE);
|
||||
- console.log('adding OkHttp3 dependency to pre 0.28 project .. ')
|
||||
- main = String(main).replace('//{RNFetchBlob_PRE_0.28_DEPDENDENCY}', "compile 'com.squareup.okhttp3:okhttp:3.4.1'");
|
||||
- fs.writeFileSync(PACKAGE_GRADLE, main);
|
||||
- console.log('adding OkHttp3 dependency to pre 0.28 project .. ok')
|
||||
- }
|
||||
-
|
||||
- console.log('Add Android permissions => ' + (addAndroidPermissions == "true"))
|
||||
-
|
||||
- if(addAndroidPermissions) {
|
||||
-
|
||||
- // set file access permission for Android < 6.0
|
||||
- fs.readFile(MANIFEST_PATH, function(err, data) {
|
||||
-
|
||||
- if(err)
|
||||
- console.log('failed to locate AndroidManifest.xml file, you may have to add file access permission manually.');
|
||||
- else {
|
||||
-
|
||||
- console.log('RNFetchBlob patching AndroidManifest.xml .. ');
|
||||
- // append fs permission
|
||||
- data = String(data).replace(
|
||||
- '<uses-permission android:name="android.permission.INTERNET" />',
|
||||
- '<uses-permission android:name="android.permission.INTERNET" />\n <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> '
|
||||
- )
|
||||
- // append DOWNLOAD_COMPLETE intent permission
|
||||
- data = String(data).replace(
|
||||
- '<category android:name="android.intent.category.LAUNCHER" />',
|
||||
- '<category android:name="android.intent.category.LAUNCHER" />\n <action android:name="android.intent.action.DOWNLOAD_COMPLETE"/>'
|
||||
- )
|
||||
- fs.writeFileSync(MANIFEST_PATH, data);
|
||||
- console.log('RNFetchBlob patching AndroidManifest.xml .. ok');
|
||||
-
|
||||
- }
|
||||
-
|
||||
- })
|
||||
- }
|
||||
- else {
|
||||
- console.log(
|
||||
- '\033[95mrn-fetch-blob \033[97mwill not automatically add Android permissions after \033[92m0.9.4 '+
|
||||
- '\033[97mplease run the following command if you want to add default permissions :\n\n' +
|
||||
- '\033[96m\tRNFB_ANDROID_PERMISSIONS=true react-native link \n')
|
||||
- }
|
||||
-
|
||||
- function checkVersion() {
|
||||
- console.log('RNFetchBlob checking app version ..');
|
||||
- return parseFloat(/\d\.\d+(?=\.)/.exec(package.dependencies['react-native']));
|
||||
- }
|
||||
-
|
||||
-} catch(err) {
|
||||
- console.log(
|
||||
- '\033[95mrn-fetch-blob\033[97m link \033[91mFAILED \033[97m\nCould not automatically link package :'+
|
||||
- err.stack +
|
||||
- 'please follow the instructions to manually link the library : ' +
|
||||
- '\033[4mhttps://github.com/joltup/rn-fetch-blob/wiki/Manually-Link-Package\n')
|
||||
-}
|
||||
@@ -48,6 +48,7 @@ import { eOnLoadNote } from "../app/utils/events";
|
||||
import { Editor } from "./editor";
|
||||
import { Search } from "./search";
|
||||
import { initDatabase, useShareStore } from "./store";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
const getLinkPreview = (url) => {
|
||||
return getPreviewData(url, 5000);
|
||||
};
|
||||
@@ -355,7 +356,14 @@ const ShareView = ({ quicknote = false }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
await db.sync(false, false);
|
||||
const status = await NetInfo.fetch();
|
||||
if (status.isInternetReachable) {
|
||||
try {
|
||||
await db.sync(false, false);
|
||||
} catch (e) {
|
||||
console.log(e, e.stack);
|
||||
}
|
||||
}
|
||||
await Storage.write("notesAddedFromIntent", "added");
|
||||
close();
|
||||
setLoading(false);
|
||||
|
||||
@@ -256,6 +256,23 @@ test("select notes using Shift+Click upwards", async ({ page }, info) => {
|
||||
expect(await notesList[0].isFocused()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("using Shift+Click when no notes are selected should not crash the app", async ({
|
||||
page
|
||||
}, info) => {
|
||||
info.setTimeout(60 * 1000);
|
||||
|
||||
const { notes } = await populateList(page, 5);
|
||||
await page.reload();
|
||||
|
||||
const note = await notes.findNote({ title: "Test note 3" });
|
||||
|
||||
await page.keyboard.down("Shift");
|
||||
await note?.click();
|
||||
await page.keyboard.up("Shift");
|
||||
|
||||
expect(await notes.isEmpty()).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Ctrl+Click to select/unselect notes", async ({ page }, info) => {
|
||||
info.setTimeout(60 * 1000);
|
||||
const { notesList, notes } = await populateList(page, 10);
|
||||
|
||||
46
apps/web/__tests__/base64-stream.test.ts
Normal file
46
apps/web/__tests__/base64-stream.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
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 "./bootstrap";
|
||||
import { test } from "vitest";
|
||||
import { Base64DecoderStream } from "../src/utils/streams/base64-decoder-stream";
|
||||
import { consumeReadableStream } from "../src/utils/stream";
|
||||
import { createReadStream, readFileSync } from "fs";
|
||||
import { Readable } from "stream";
|
||||
import path from "path";
|
||||
|
||||
test("streamed base64 decoder should output same as non-streamed", async (t) => {
|
||||
const expected = readFileSync(
|
||||
path.join(__dirname, "..", "__e2e__", "data", "importer-data.zip"),
|
||||
"base64"
|
||||
);
|
||||
const fileStream = Readable.toWeb(
|
||||
createReadStream(
|
||||
path.join(__dirname, "..", "__e2e__", "data", "importer-data.zip")
|
||||
)
|
||||
) as ReadableStream<Uint8Array>;
|
||||
|
||||
t.expect(
|
||||
(
|
||||
await consumeReadableStream(
|
||||
fileStream.pipeThrough(new Base64DecoderStream("base64"))
|
||||
)
|
||||
).join("")
|
||||
).toBe(expected);
|
||||
});
|
||||
34
apps/web/__tests__/bootstrap.ts
Normal file
34
apps/web/__tests__/bootstrap.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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 {
|
||||
TransformStream,
|
||||
ReadableStream,
|
||||
WritableStream
|
||||
} from "node:stream/web";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
globalThis.TransformStream = TransformStream;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
globalThis.ReadableStream = ReadableStream;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
globalThis.WritableStream = WritableStream;
|
||||
45
apps/web/__tests__/chunked-stream.test.ts
Normal file
45
apps/web/__tests__/chunked-stream.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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 "./bootstrap";
|
||||
import { test } from "vitest";
|
||||
import { ChunkedStream } from "../src/utils/streams/chunked-stream";
|
||||
import { Readable } from "stream";
|
||||
import { createReadStream } from "fs";
|
||||
import { consumeReadableStream } from "../src/utils/stream";
|
||||
import { xxhash64 } from "hash-wasm";
|
||||
import path from "path";
|
||||
|
||||
const CHUNK_SIZE = 512 * 1024;
|
||||
test("chunked stream should create equal sized chunks", async (t) => {
|
||||
const chunks = await consumeReadableStream(
|
||||
(
|
||||
Readable.toWeb(
|
||||
createReadStream(
|
||||
path.join(__dirname, "..", "__e2e__", "data", "importer-data.zip")
|
||||
)
|
||||
) as ReadableStream<Uint8Array>
|
||||
).pipeThrough(new ChunkedStream(CHUNK_SIZE))
|
||||
);
|
||||
|
||||
t.expect(await Promise.all(chunks.map((a) => xxhash64(a)))).toMatchObject([
|
||||
"6234b76401d9eb97",
|
||||
"338834da3f6500b2"
|
||||
]);
|
||||
});
|
||||
4
apps/web/desktop/package-lock.json
generated
4
apps/web/desktop/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "2.4.9",
|
||||
"version": "2.5.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@notesnook/desktop",
|
||||
"version": "2.4.9",
|
||||
"version": "2.5.0",
|
||||
"dependencies": {
|
||||
"diary": "^0.3.1",
|
||||
"electron-updater": "^5.3.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@notesnook/desktop",
|
||||
"productName": "Notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "2.4.9",
|
||||
"version": "2.5.0",
|
||||
"appAppleId": "1544027013",
|
||||
"private": true,
|
||||
"main": "./build/electron.js",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { createReadStream } from "fs";
|
||||
import { extname, normalize } from "path";
|
||||
import { logger } from "./logger";
|
||||
import { URL } from "url";
|
||||
import { Blob } from "buffer";
|
||||
|
||||
const FILE_NOT_FOUND = -6;
|
||||
const BASE_PATH = isDevelopment() ? "../public" : "";
|
||||
@@ -67,7 +68,31 @@ function registerProtocol() {
|
||||
mimeType: extensionToMimeType[fileExtension]
|
||||
});
|
||||
} else {
|
||||
protocol.uninterceptProtocol(PROTOCOL);
|
||||
var response;
|
||||
try {
|
||||
const body = await getBody(request);
|
||||
response = await fetch(request.url, {
|
||||
...request,
|
||||
body,
|
||||
headers: {
|
||||
...request.headers
|
||||
// origin: `${PROTOCOL}://${HOSTNAME}/`
|
||||
},
|
||||
referrer: request.referrer,
|
||||
redirect: "manual"
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logger.error(`Error sending request to `, request.url, "Error: ", e);
|
||||
callback({ statusCode: 400 });
|
||||
return;
|
||||
}
|
||||
callback({
|
||||
statusCode: response.status,
|
||||
data: response.body,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
mimeType: response.headers.get("Content-Type")
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -85,5 +110,29 @@ function shouldInterceptRequest(url) {
|
||||
return shouldIntercept && !bypassedRoutes.includes(url.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Electron.ProtocolRequest} request
|
||||
*/
|
||||
async function getBody(request) {
|
||||
/**
|
||||
* @type {Electron.Session}
|
||||
*/
|
||||
const session = globalThis?.window?.webContents?.session;
|
||||
|
||||
const blobParts = [];
|
||||
if (!request.uploadData || !request.uploadData.length) return null;
|
||||
for (let data of request.uploadData) {
|
||||
if (data.type === "rawData") {
|
||||
blobParts.push(new Uint8Array(data.bytes));
|
||||
} else if (session && data.type === "blob") {
|
||||
const buffer = await session.getBlobData(data.blobUUID);
|
||||
blobParts.push(new Uint8Array(buffer));
|
||||
}
|
||||
}
|
||||
const blob = new Blob(blobParts);
|
||||
return await blob.arrayBuffer();
|
||||
}
|
||||
|
||||
const PROTOCOL_URL = `${PROTOCOL}://${HOSTNAME}/`;
|
||||
export { registerProtocol, PROTOCOL_URL };
|
||||
|
||||
19331
apps/web/package-lock.json
generated
19331
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@notesnook/web",
|
||||
"description": "Your private note taking space",
|
||||
"version": "2.4.9",
|
||||
"version": "2.5.0",
|
||||
"private": true,
|
||||
"main": "./src/app.js",
|
||||
"homepage": "https://notesnook.com/",
|
||||
@@ -27,12 +27,14 @@
|
||||
"@notesnook/streamable-fs": "*",
|
||||
"@notesnook/theme": "*",
|
||||
"@notesnook/web-clipper": "*",
|
||||
"@react-pdf-viewer/core": "^3.12.0",
|
||||
"@react-pdf-viewer/toolbar": "^3.12.0",
|
||||
"@tanstack/react-virtual": "^3.0.0-beta.18",
|
||||
"@theme-ui/components": "^0.14.7",
|
||||
"@theme-ui/core": "^0.14.7",
|
||||
"allotment": "^1.12.1",
|
||||
"async-mutex": "^0.3.2",
|
||||
"axios": "^0.21.4",
|
||||
"axios": "^1.3.4",
|
||||
"clipboard-polyfill": "^3.0.3",
|
||||
"comlink": "^4.3.1",
|
||||
"cronosjs": "^1.7.1",
|
||||
@@ -48,6 +50,7 @@
|
||||
"localforage-driver-memory": "^1.0.5",
|
||||
"mac-scrollbar": "^0.10.3",
|
||||
"marked": "^4.1.0",
|
||||
"pdfjs-dist": "^3.6.172",
|
||||
"phone": "^3.1.14",
|
||||
"platform": "^1.3.6",
|
||||
"print-js": "^1.6.0",
|
||||
@@ -85,6 +88,7 @@
|
||||
"env-cmd": "^10.1.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"find-process": "^1.4.4",
|
||||
"happy-dom": "^8.9.0",
|
||||
"ip": "^1.1.8",
|
||||
"lorem-ipsum": "^2.0.4",
|
||||
"otplib": "^12.0.1",
|
||||
@@ -94,6 +98,7 @@
|
||||
"react-dom": "17.0.2",
|
||||
"source-map-explorer": "^2.5.2",
|
||||
"typescript": "^4.8.2",
|
||||
"vitest": "^0.29.8",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
"worker-loader": "^3.0.8"
|
||||
},
|
||||
|
||||
22
apps/web/public/pdf.worker.min.js
vendored
Normal file
22
apps/web/public/pdf.worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
154
apps/web/public/stream-saver-sw.js
Normal file
154
apps/web/public/stream-saver-sw.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
const map = new Map();
|
||||
|
||||
// This should be called once per download
|
||||
// Each event has a dataChannel that the data will be piped through
|
||||
self.onmessage = (event) => {
|
||||
// We send a heartbeat every x second to keep the
|
||||
// service worker alive if a transferable stream is not sent
|
||||
if (event.data === "ping") {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
const downloadUrl =
|
||||
data.url ||
|
||||
self.registration.scope +
|
||||
Math.random() +
|
||||
"/" +
|
||||
(typeof data === "string" ? data : data.filename);
|
||||
const port = event.ports[0];
|
||||
const metadata = new Array(3); // [stream, data, port]
|
||||
|
||||
metadata[1] = data;
|
||||
metadata[2] = port;
|
||||
|
||||
if (event.data.transferringReadable) {
|
||||
port.onmessage = (evt) => {
|
||||
port.onmessage = null;
|
||||
metadata[0] = evt.data.readableStream;
|
||||
};
|
||||
} else {
|
||||
metadata[0] = createStream(port);
|
||||
}
|
||||
|
||||
map.set(downloadUrl, metadata);
|
||||
port.postMessage({ download: downloadUrl });
|
||||
};
|
||||
|
||||
function createStream(port) {
|
||||
// ReadableStream is only supported by chrome 52
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
// When we receive data on the messageChannel, we write
|
||||
port.onmessage = ({ data }) => {
|
||||
if (data === "end") {
|
||||
return controller.close();
|
||||
}
|
||||
|
||||
if (data === "abort") {
|
||||
controller.error("Aborted the download");
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(data);
|
||||
};
|
||||
},
|
||||
cancel(reason) {
|
||||
console.log("user aborted", reason);
|
||||
port.postMessage({ abort: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.onfetch = (event) => {
|
||||
const url = event.request.url;
|
||||
|
||||
// this only works for Firefox
|
||||
if (url.endsWith("/ping")) {
|
||||
return event.respondWith(new Response("pong"));
|
||||
}
|
||||
|
||||
const metadata = map.get(url);
|
||||
if (!metadata) return null;
|
||||
|
||||
const [stream, data, port] = metadata;
|
||||
|
||||
map.delete(url);
|
||||
|
||||
// Not comfortable letting any user control all headers
|
||||
// so we only copy over the length & disposition
|
||||
const responseHeaders = new Headers({
|
||||
"Content-Type": "application/octet-stream; charset=utf-8",
|
||||
|
||||
// To be on the safe side, The link can be opened in a iframe.
|
||||
// but octet-stream should stop it.
|
||||
"Content-Security-Policy": "default-src 'none'",
|
||||
"X-Content-Security-Policy": "default-src 'none'",
|
||||
"X-WebKit-CSP": "default-src 'none'",
|
||||
"X-XSS-Protection": "1; mode=block"
|
||||
});
|
||||
|
||||
let headers = new Headers(data.headers || {});
|
||||
|
||||
if (headers.has("Content-Length")) {
|
||||
responseHeaders.set("Content-Length", headers.get("Content-Length"));
|
||||
}
|
||||
|
||||
if (headers.has("Content-Disposition")) {
|
||||
responseHeaders.set(
|
||||
"Content-Disposition",
|
||||
headers.get("Content-Disposition")
|
||||
);
|
||||
}
|
||||
|
||||
// data, data.filename and size should not be used anymore
|
||||
if (data.size) {
|
||||
console.warn("Depricated");
|
||||
responseHeaders.set("Content-Length", data.size);
|
||||
}
|
||||
|
||||
let fileName = typeof data === "string" ? data : data.filename;
|
||||
if (fileName) {
|
||||
console.warn("Depricated");
|
||||
// Make filename RFC5987 compatible
|
||||
fileName = encodeURIComponent(fileName)
|
||||
.replace(/['()]/g, escape)
|
||||
.replace(/\*/g, "%2A");
|
||||
responseHeaders.set(
|
||||
"Content-Disposition",
|
||||
"attachment; filename*=UTF-8''" + fileName
|
||||
);
|
||||
}
|
||||
|
||||
event.respondWith(new Response(stream, { headers: responseHeaders }));
|
||||
|
||||
port.postMessage({ debug: "Download started" });
|
||||
};
|
||||
@@ -45,11 +45,8 @@ import { isTesting } from "./utils/platform";
|
||||
import { updateStatus, removeStatus, getStatus } from "./hooks/use-status";
|
||||
import { showToast } from "./utils/toast";
|
||||
import { interruptedOnboarding } from "./components/dialogs/onboarding-dialog";
|
||||
import { WebExtensionRelay } from "./utils/web-extension-relay";
|
||||
import { hashNavigate } from "./navigation";
|
||||
|
||||
const relay = new WebExtensionRelay();
|
||||
|
||||
export default function AppEffects({ setShow }) {
|
||||
const refreshNavItems = useStore((store) => store.refreshNavItems);
|
||||
const updateLastSynced = useStore((store) => store.updateLastSynced);
|
||||
@@ -103,7 +100,6 @@ export default function AppEffects({ setShow }) {
|
||||
await showOnboardingDialog(interruptedOnboarding());
|
||||
await showFeatureDialog("highlights");
|
||||
await scheduleBackups();
|
||||
relay.connect();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
:root {
|
||||
--focus-border: var(--primary);
|
||||
--separator-border: var(--border);
|
||||
--sash-size: 10px;
|
||||
--sash-hover-size: 4px;
|
||||
}
|
||||
|
||||
/* open-sans-regular - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
|
||||
@font-face {
|
||||
font-family: "Open Sans";
|
||||
@@ -95,6 +102,8 @@
|
||||
format("truetype");
|
||||
}
|
||||
|
||||
.rpv-core__text-layer,
|
||||
.rpv-core__text-layer *,
|
||||
.selectable,
|
||||
.selectable *,
|
||||
input,
|
||||
@@ -120,6 +129,17 @@ textarea,
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.rpv-core__text-layer-text::selection {
|
||||
background-color: var(--dimPrimary) !important;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.rpv-core__text-layer-text::-moz-selection {
|
||||
/* Code for Firefox */
|
||||
background-color: var(--dimPrimary) !important;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
*::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
@@ -165,13 +185,6 @@ textarea,
|
||||
width: 1px !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--focus-border: var(--primary);
|
||||
--separator-border: var(--border);
|
||||
--sash-size: 10px;
|
||||
--sash-hover-size: 4px;
|
||||
}
|
||||
|
||||
.route#settings,
|
||||
#mainRouteContainer {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -25,7 +25,6 @@ import useTablet from "./hooks/use-tablet";
|
||||
import { LazyMotion, domAnimation } from "framer-motion";
|
||||
import useDatabase from "./hooks/use-database";
|
||||
import { Allotment, LayoutPriority } from "allotment";
|
||||
import "allotment/dist/style.css";
|
||||
import Config from "./utils/config";
|
||||
import { useStore } from "./stores/app-store";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
@@ -35,6 +34,9 @@ import StatusBar from "./components/status-bar";
|
||||
import { EditorLoader } from "./components/loaders/editor-loader";
|
||||
import { FlexScrollContainer } from "./components/scroll-container";
|
||||
import CachedRouter from "./components/cached-router";
|
||||
import { WebExtensionRelay } from "./utils/web-extension-relay";
|
||||
|
||||
new WebExtensionRelay();
|
||||
|
||||
const GlobalMenuWrapper = React.lazy(() =>
|
||||
import("./components/global-menu-wrapper")
|
||||
|
||||
@@ -20,8 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import FS from "../interfaces/fs";
|
||||
import { db } from "./db";
|
||||
|
||||
export async function downloadAttachment(hash) {
|
||||
const attachment = db.attachments.attachment(hash);
|
||||
async function download(hash: string) {
|
||||
const attachment = db.attachments?.attachment(hash);
|
||||
if (!attachment) return;
|
||||
const downloadResult = await db.fs.downloadFile(
|
||||
attachment.metadata.hash,
|
||||
@@ -31,9 +31,17 @@ export async function downloadAttachment(hash) {
|
||||
);
|
||||
if (!downloadResult) throw new Error("Failed to download file.");
|
||||
|
||||
const key = await db.attachments.decryptKey(attachment.key);
|
||||
const key = await db.attachments?.decryptKey(attachment.key);
|
||||
if (!key) throw new Error("Invalid key for attachment.");
|
||||
|
||||
return { key, attachment };
|
||||
}
|
||||
|
||||
export async function saveAttachment(hash: string) {
|
||||
const response = await download(hash);
|
||||
if (!response) return;
|
||||
|
||||
const { attachment, key } = response;
|
||||
await FS.saveFile(attachment.metadata.hash, {
|
||||
key,
|
||||
iv: attachment.iv,
|
||||
@@ -43,23 +51,51 @@ export async function downloadAttachment(hash) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkAttachment(hash) {
|
||||
const attachment = db.attachments.attachment(hash);
|
||||
type OutputTypeToReturnType = {
|
||||
blob: Blob;
|
||||
base64: string;
|
||||
text: string;
|
||||
};
|
||||
export async function downloadAttachment<
|
||||
TType extends "blob" | "base64" | "text",
|
||||
TOutputType = OutputTypeToReturnType[TType]
|
||||
>(hash: string, type: TType): Promise<TOutputType | undefined> {
|
||||
const response = await download(hash);
|
||||
if (!response) return;
|
||||
const { attachment, key } = response;
|
||||
|
||||
if (type === "base64" || type === "text")
|
||||
return (await db.attachments?.read(hash, type)) as TOutputType;
|
||||
|
||||
const blob = await FS.decryptFile(attachment.metadata.hash, {
|
||||
key,
|
||||
iv: attachment.iv,
|
||||
name: attachment.metadata.filename,
|
||||
type: attachment.metadata.type,
|
||||
isUploaded: !!attachment.dateUploaded
|
||||
});
|
||||
|
||||
if (!blob) return;
|
||||
return blob as TOutputType;
|
||||
}
|
||||
|
||||
export async function checkAttachment(hash: string) {
|
||||
const attachment = db.attachments?.attachment(hash);
|
||||
if (!attachment) return { failed: "Attachment not found." };
|
||||
|
||||
try {
|
||||
const size = await FS.getUploadedFileSize(hash);
|
||||
if (size <= 0) return { failed: "File length is 0." };
|
||||
} catch (e) {
|
||||
return { failed: e.message };
|
||||
return { failed: e instanceof Error ? e.message : "Unknown error." };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ABYTES = 17;
|
||||
export function getTotalSize(attachments) {
|
||||
export function getTotalSize(attachments: any[]) {
|
||||
let size = 0;
|
||||
for (let attachment of attachments) {
|
||||
for (const attachment of attachments) {
|
||||
size += attachment.length + ABYTES;
|
||||
}
|
||||
return size;
|
||||
@@ -88,6 +88,12 @@ export function closeOpenedDialog() {
|
||||
dialogs.forEach((elem) => elem.remove());
|
||||
}
|
||||
|
||||
export function showAddTagsDialog(noteIds: string[]) {
|
||||
return showDialog("AddTagsDialog", (Dialog, perform) => (
|
||||
<Dialog onClose={(res) => perform(res)} noteIds={noteIds} />
|
||||
));
|
||||
}
|
||||
|
||||
export function showAddNotebookDialog() {
|
||||
return showDialog("AddNotebookDialog", (Dialog, perform) => (
|
||||
<Dialog
|
||||
@@ -211,8 +217,9 @@ export function showError(title: string, message: string) {
|
||||
export function showMultiDeleteConfirmation(length: number) {
|
||||
return confirm({
|
||||
title: `Delete ${length} items?`,
|
||||
message: `These items will be **kept in your Trash for ${db.settings?.getTrashCleanupInterval() || 7
|
||||
} days** after which they will be permanently deleted.`,
|
||||
message: `These items will be **kept in your Trash for ${
|
||||
db.settings?.getTrashCleanupInterval() || 7
|
||||
} days** after which they will be permanently deleted.`,
|
||||
positiveButtonText: "Yes",
|
||||
negativeButtonText: "No"
|
||||
});
|
||||
@@ -331,6 +338,12 @@ export function showMoveNoteDialog(noteIds: string[]) {
|
||||
));
|
||||
}
|
||||
|
||||
export function showBillingHistoryDialog() {
|
||||
return showDialog("BillingHistoryDialog", (Dialog, perform) => (
|
||||
<Dialog onClose={(res: boolean) => perform(res)} />
|
||||
));
|
||||
}
|
||||
|
||||
function getDialogData(type: string) {
|
||||
switch (type) {
|
||||
case "create_vault":
|
||||
|
||||
@@ -17,8 +17,8 @@ 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 { db } from "../common/db";
|
||||
import { showPasswordDialog } from "../common/dialog-controller";
|
||||
import { db } from "./db";
|
||||
import { showPasswordDialog } from "./dialog-controller";
|
||||
import { showToast } from "../utils/toast";
|
||||
|
||||
class Vault {
|
||||
|
||||
@@ -1,250 +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 { Box, Flex, Text } from "@theme-ui/components";
|
||||
import { formatBytes } from "../../utils/filename";
|
||||
import ListItem from "../list-item";
|
||||
import * as Icon from "../icons";
|
||||
import { downloadAttachment } from "../../common/attachments";
|
||||
import { reuploadAttachment } from "../editor/picker";
|
||||
import { store } from "../../stores/attachment-store";
|
||||
import { db } from "../../common/db";
|
||||
import { Multiselect } from "../../common/multi-select";
|
||||
import {
|
||||
closeOpenedDialog,
|
||||
showAttachmentsDialog,
|
||||
showPromptDialog
|
||||
} from "../../common/dialog-controller";
|
||||
import { hashNavigate } from "../../navigation";
|
||||
import { showToast } from "../../utils/toast";
|
||||
|
||||
const workStatusMap = {
|
||||
recheck: "Rechecking...",
|
||||
delete: "Deleting..."
|
||||
};
|
||||
function Attachment({ item, isCompact, index }) {
|
||||
const attachment = item;
|
||||
const status = attachment.status;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
selectable
|
||||
isCompact={isCompact}
|
||||
item={{ ...attachment, title: attachment.metadata.filename }}
|
||||
title={attachment.metadata.filename}
|
||||
body={
|
||||
attachment.failed && <Text variant={"error"}>{attachment.failed}</Text>
|
||||
}
|
||||
menu={{
|
||||
items: menuItems,
|
||||
extraData: { attachment }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isCompact) showAttachmentsDialog();
|
||||
}}
|
||||
footer={
|
||||
isCompact ? (
|
||||
<Flex
|
||||
sx={{
|
||||
fontSize: "subBody",
|
||||
color: "fontTertiary",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{status ? (
|
||||
<Text variant="subBody" title={`${status.type}ing`}>
|
||||
{formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text mr={1}>{formatBytes(attachment.length)}</Text>
|
||||
)}
|
||||
{attachment.failed && (
|
||||
<Icon.AttachmentError
|
||||
sx={{ flexShrink: 0 }}
|
||||
color={"error"}
|
||||
size={13}
|
||||
title={attachment.failed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{attachment.isDeleting ? (
|
||||
<Icon.Loading
|
||||
sx={{ flexShrink: 0 }}
|
||||
size={13}
|
||||
title={"Deleting.."}
|
||||
/>
|
||||
) : attachment.dateUploaded ? (
|
||||
<Icon.DoubleCheckmark
|
||||
sx={{ flexShrink: 0 }}
|
||||
color={"primary"}
|
||||
size={13}
|
||||
title={"Uploaded"}
|
||||
/>
|
||||
) : (
|
||||
<Icon.Checkmark
|
||||
sx={{ flexShrink: 0 }}
|
||||
color={"icon"}
|
||||
size={13}
|
||||
title={"Waiting for upload"}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
) : attachment.working ? (
|
||||
<Flex>
|
||||
<Icon.Loading size={13} />
|
||||
<Text variant={"subBody"} ml={1}>
|
||||
{workStatusMap[attachment.working]}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
{status && (
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Text variant="subBody">
|
||||
{formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}{" "}
|
||||
({status.type}ing)
|
||||
</Text>
|
||||
<Box
|
||||
sx={{
|
||||
my: 1,
|
||||
bg: "primary",
|
||||
height: "2px",
|
||||
width: `${status.progress}%`
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<Flex
|
||||
sx={{
|
||||
fontSize: "subBody",
|
||||
color: "fontTertiary",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Text mr={1}>{formatBytes(attachment.length)}</Text>
|
||||
<Text mr={1}>{attachment.metadata.type}</Text>
|
||||
{attachment.noteIds && (
|
||||
<Text mr={1}>{attachment.noteIds.length} notes</Text>
|
||||
)}
|
||||
{attachment.metadata.hash && (
|
||||
<Text className="selectable" mr={0}>
|
||||
{attachment.metadata.hash}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
index={index}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default Attachment;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: "notes",
|
||||
title: "Notes",
|
||||
icon: Icon.References,
|
||||
items: ({ attachment }) =>
|
||||
attachment.noteIds.reduce((prev, curr) => {
|
||||
const note = db.notes.note(curr);
|
||||
if (!note)
|
||||
prev.push({
|
||||
key: curr,
|
||||
title: `Note with id ${curr}`,
|
||||
onClick: () => showToast("error", "This note does not exist.")
|
||||
});
|
||||
else
|
||||
prev.push({
|
||||
key: note.id,
|
||||
title: note.title,
|
||||
onClick: () => {
|
||||
hashNavigate(`/notes/${curr}/edit`);
|
||||
closeOpenedDialog();
|
||||
}
|
||||
});
|
||||
return prev;
|
||||
}, [])
|
||||
},
|
||||
{
|
||||
key: "recheck",
|
||||
title: () => "Recheck",
|
||||
icon: Icon.DoubleCheckmark,
|
||||
disabled: ({ attachment }) =>
|
||||
!attachment.dateUploaded ? "This attachment is not uploaded yet." : false,
|
||||
onClick: async ({ items }) => {
|
||||
await store.recheck(items.map((i) => i.metadata.hash));
|
||||
},
|
||||
multiSelect: true
|
||||
},
|
||||
{
|
||||
key: "rename",
|
||||
title: () => "Rename",
|
||||
icon: Icon.Rename,
|
||||
onClick: async ({ attachment }) => {
|
||||
const newName = await showPromptDialog({
|
||||
title: "Rename attachment",
|
||||
description: attachment.metadata.filename,
|
||||
defaultValue: attachment.metadata.filename
|
||||
});
|
||||
if (!newName) return;
|
||||
await store.rename(attachment.metadata.hash, newName);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
title: ({ attachment }) =>
|
||||
attachment.status?.type === "download" ? "Cancel download" : "Download",
|
||||
icon: Icon.Download,
|
||||
disabled: ({ attachment }) =>
|
||||
!attachment.dateUploaded ? "This attachment is not uploaded yet." : false,
|
||||
onClick: async ({ attachment }) => {
|
||||
const isDownloading = attachment.status?.type === "download";
|
||||
if (isDownloading) {
|
||||
await db.fs.cancel(attachment.metadata.hash, "download");
|
||||
} else await downloadAttachment(attachment.metadata.hash);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "reupload",
|
||||
title: ({ attachment }) =>
|
||||
attachment.status?.type === "upload" ? "Cancel upload" : "Reupload",
|
||||
icon: Icon.Reupload,
|
||||
onClick: async ({ attachment }) => {
|
||||
const isDownloading = attachment.status?.type === "upload";
|
||||
if (isDownloading) {
|
||||
await db.fs.cancel(attachment.metadata.hash, "upload");
|
||||
} else
|
||||
await reuploadAttachment(
|
||||
attachment.metadata.type,
|
||||
attachment.metadata.hash
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "permanent-delete",
|
||||
color: "error",
|
||||
iconColor: "error",
|
||||
title: () => "Delete permanently",
|
||||
icon: Icon.DeleteForver,
|
||||
onClick: ({ items }) => Multiselect.deleteAttachments(items),
|
||||
multiSelect: true
|
||||
}
|
||||
];
|
||||
333
apps/web/src/components/attachment/index.tsx
Normal file
333
apps/web/src/components/attachment/index.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
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 { Box, Checkbox, Flex, Label, Text } from "@theme-ui/components";
|
||||
import { formatBytes } from "../../utils/filename";
|
||||
import {
|
||||
AttachmentError,
|
||||
Checkmark,
|
||||
DeleteForver,
|
||||
DoubleCheckmark,
|
||||
Download,
|
||||
FileDocument,
|
||||
FileGeneral,
|
||||
FileImage,
|
||||
FilePDF,
|
||||
FileVideo,
|
||||
FileWebClip,
|
||||
Icon,
|
||||
Loading,
|
||||
References,
|
||||
Rename,
|
||||
Reupload,
|
||||
Uploading
|
||||
} from "../icons";
|
||||
import { formatDate } from "@notesnook/core/utils/date";
|
||||
import { showToast } from "../../utils/toast";
|
||||
import { hashNavigate } from "../../navigation";
|
||||
import {
|
||||
closeOpenedDialog,
|
||||
showPromptDialog
|
||||
} from "../../common/dialog-controller";
|
||||
import { store } from "../../stores/attachment-store";
|
||||
import { db } from "../../common/db";
|
||||
import { saveAttachment } from "../../common/attachments";
|
||||
import { reuploadAttachment } from "../editor/picker";
|
||||
import { Multiselect } from "../../common/multi-select";
|
||||
import { Menu } from "../../hooks/use-menu";
|
||||
import {
|
||||
DocumentMimeTypes,
|
||||
WebClipMimeType,
|
||||
PDFMimeType
|
||||
} from "@notesnook/core/utils/filename";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppEventManager, AppEvents } from "../../common/app-events";
|
||||
|
||||
const FILE_ICONS: Record<string, Icon> = {
|
||||
"image/": FileImage,
|
||||
"video/": FileVideo,
|
||||
[WebClipMimeType]: FileWebClip
|
||||
};
|
||||
|
||||
for (const mimeType of DocumentMimeTypes) {
|
||||
FILE_ICONS[mimeType] = mimeType === PDFMimeType ? FilePDF : FileDocument;
|
||||
}
|
||||
|
||||
function getFileIcon(type: string) {
|
||||
for (const mime in FILE_ICONS) {
|
||||
if (type.startsWith(mime))
|
||||
return FILE_ICONS[mime as keyof typeof FILE_ICONS];
|
||||
}
|
||||
return FileGeneral;
|
||||
}
|
||||
|
||||
type AttachmentProgressStatus = {
|
||||
type: "download" | "upload";
|
||||
loaded: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type AttachmentProps = {
|
||||
attachment: any;
|
||||
isSelected?: boolean;
|
||||
onSelected?: () => void;
|
||||
compact?: boolean;
|
||||
};
|
||||
export function Attachment({
|
||||
attachment,
|
||||
isSelected,
|
||||
onSelected,
|
||||
compact
|
||||
}: AttachmentProps) {
|
||||
const [status, setStatus] = useState<AttachmentProgressStatus>();
|
||||
|
||||
useEffect(() => {
|
||||
const event = AppEventManager.subscribe(
|
||||
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
|
||||
(progress: any) => {
|
||||
if (progress.hash === attachment.metadata.hash) {
|
||||
const percent = Math.round((progress.loaded / progress.total) * 100);
|
||||
setStatus(
|
||||
percent < 100
|
||||
? {
|
||||
type: progress.type,
|
||||
loaded: progress.loaded,
|
||||
total: progress.total
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
event.unsubscribe();
|
||||
};
|
||||
}, [attachment.metadata.hash]);
|
||||
|
||||
const Icon = getFileIcon(attachment.metadata.type);
|
||||
return (
|
||||
<Box
|
||||
as="tr"
|
||||
sx={{ height: 30, ":hover": { bg: "hover" } }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
Menu.openMenu(AttachmentMenuItems, {
|
||||
attachment,
|
||||
status
|
||||
});
|
||||
}}
|
||||
onClick={onSelected}
|
||||
>
|
||||
{!compact && (
|
||||
<td>
|
||||
<Label>
|
||||
<Checkbox
|
||||
sx={{ width: 18, height: 18 }}
|
||||
checked={isSelected}
|
||||
onChange={onSelected}
|
||||
/>
|
||||
</Label>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
maxWidth: compact ? 180 : "95%"
|
||||
}}
|
||||
>
|
||||
{status ? (
|
||||
status.type === "download" ? (
|
||||
<Download size={16} color="primary" />
|
||||
) : (
|
||||
<Uploading size={16} color="primary" />
|
||||
)
|
||||
) : attachment.failed ? (
|
||||
<AttachmentError
|
||||
color={"error"}
|
||||
size={16}
|
||||
title={attachment.failed}
|
||||
/>
|
||||
) : attachment.working ? (
|
||||
<Loading size={16} />
|
||||
) : (
|
||||
<Icon size={16} />
|
||||
)}
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
ml: 1,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
}}
|
||||
>
|
||||
{attachment.metadata.filename}
|
||||
</Text>
|
||||
</Flex>
|
||||
</td>
|
||||
<Text as="td" variant="body">
|
||||
{attachment.isDeleting ? (
|
||||
<Loading sx={{ flexShrink: 0 }} size={16} title={"Deleting.."} />
|
||||
) : attachment.dateUploaded ? (
|
||||
<DoubleCheckmark
|
||||
sx={{ flexShrink: 0 }}
|
||||
color={"primary"}
|
||||
size={16}
|
||||
title={"Uploaded"}
|
||||
/>
|
||||
) : (
|
||||
<Checkmark
|
||||
sx={{ flexShrink: 0 }}
|
||||
color={"icon"}
|
||||
size={16}
|
||||
title={"Waiting for upload"}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
<Text as="td" variant="body" sx={{ color: status ? "primary" : "text" }}>
|
||||
{status ? (
|
||||
<>
|
||||
{formatBytes(status.loaded, 1)}/{formatBytes(status.total, 1)}
|
||||
</>
|
||||
) : (
|
||||
formatBytes(attachment.length, compact ? 1 : 2)
|
||||
)}
|
||||
</Text>
|
||||
{!compact && (
|
||||
<Text as="td" variant="body">
|
||||
{attachment.dateUploaded
|
||||
? formatDate(attachment.dateUploaded, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
})
|
||||
: "-"}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuActionParams = {
|
||||
attachment: any;
|
||||
status: AttachmentProgressStatus;
|
||||
};
|
||||
|
||||
type MenuItemValue<T> = T | ((options: MenuActionParams) => T);
|
||||
type MenuItem = {
|
||||
type?: "separator";
|
||||
key: string;
|
||||
title?: MenuItemValue<string>;
|
||||
icon?: MenuItemValue<Icon>;
|
||||
onClick?: (options: MenuActionParams) => void;
|
||||
disabled?: MenuItemValue<boolean | string>;
|
||||
color?: MenuItemValue<string>;
|
||||
iconColor?: MenuItemValue<string>;
|
||||
multiSelect?: boolean;
|
||||
items?: MenuItemValue<MenuItem[]>;
|
||||
};
|
||||
const AttachmentMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: "notes",
|
||||
title: "Notes",
|
||||
icon: References,
|
||||
items: ({ attachment }) =>
|
||||
(attachment.noteIds as string[]).reduce((prev, curr) => {
|
||||
const note = db.notes?.note(curr);
|
||||
if (!note)
|
||||
prev.push({
|
||||
key: curr,
|
||||
title: `Note with id ${curr}`,
|
||||
onClick: () => showToast("error", "This note does not exist.")
|
||||
});
|
||||
else
|
||||
prev.push({
|
||||
key: note.id,
|
||||
title: note.title,
|
||||
onClick: () => {
|
||||
hashNavigate(`/notes/${curr}/edit`);
|
||||
closeOpenedDialog();
|
||||
}
|
||||
});
|
||||
return prev;
|
||||
}, [] as MenuItem[])
|
||||
},
|
||||
{
|
||||
key: "recheck",
|
||||
title: () => "Recheck",
|
||||
icon: DoubleCheckmark,
|
||||
disabled: ({ attachment }) =>
|
||||
!attachment.dateUploaded ? "This attachment is not uploaded yet." : false,
|
||||
onClick: async ({ attachment }) => {
|
||||
await store.recheck([attachment.metadata.hash]);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "rename",
|
||||
title: () => "Rename",
|
||||
icon: Rename,
|
||||
onClick: async ({ attachment }) => {
|
||||
const newName = await showPromptDialog({
|
||||
title: "Rename attachment",
|
||||
description: attachment.metadata.filename,
|
||||
defaultValue: attachment.metadata.filename
|
||||
});
|
||||
if (!newName) return;
|
||||
await store.rename(attachment.metadata.hash, newName);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
title: ({ status }) =>
|
||||
status?.type === "download" ? "Cancel download" : "Download",
|
||||
icon: Download,
|
||||
disabled: ({ attachment }) =>
|
||||
!attachment.dateUploaded ? "This attachment is not uploaded yet." : false,
|
||||
onClick: async ({ attachment, status }) => {
|
||||
const isDownloading = status?.type === "download";
|
||||
if (isDownloading) {
|
||||
await db.fs.cancel(attachment.metadata.hash, "download");
|
||||
} else await saveAttachment(attachment.metadata.hash);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "reupload",
|
||||
title: ({ status }) =>
|
||||
status?.type === "upload" ? "Cancel upload" : "Reupload",
|
||||
icon: Reupload,
|
||||
onClick: async ({ attachment, status }) => {
|
||||
const isDownloading = status?.type === "upload";
|
||||
if (isDownloading) {
|
||||
await db.fs.cancel(attachment.metadata.hash, "upload");
|
||||
} else
|
||||
await reuploadAttachment(
|
||||
attachment.metadata.type,
|
||||
attachment.metadata.hash
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "permanent-delete",
|
||||
color: "error",
|
||||
iconColor: "error",
|
||||
title: () => "Delete permanently",
|
||||
icon: DeleteForver,
|
||||
onClick: ({ attachment }) => Multiselect.deleteAttachments([attachment])
|
||||
}
|
||||
];
|
||||
220
apps/web/src/components/dialogs/add-tags-dialog.tsx
Normal file
220
apps/web/src/components/dialogs/add-tags-dialog.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
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 { useCallback, useEffect, useState } from "react";
|
||||
import { Flex, Text } from "@theme-ui/components";
|
||||
import * as Icon from "../icons";
|
||||
import { db } from "../../common/db";
|
||||
import Dialog from "./dialog";
|
||||
import { useStore, store } from "../../stores/tag-store";
|
||||
import { store as notestore } from "../../stores/note-store";
|
||||
import { Perform } from "../../common/dialog-controller";
|
||||
import { FilteredList } from "../filtered-list";
|
||||
|
||||
type SelectedReference = {
|
||||
id: string;
|
||||
new: boolean;
|
||||
op: "add" | "remove";
|
||||
};
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
type: "tag" | "header";
|
||||
title: string;
|
||||
};
|
||||
type Tag = Item & { noteIds: string[] };
|
||||
|
||||
export type AddTagsDialogProps = {
|
||||
onClose: Perform;
|
||||
noteIds: string[];
|
||||
};
|
||||
|
||||
function AddTagsDialog(props: AddTagsDialogProps) {
|
||||
const { onClose, noteIds } = props;
|
||||
|
||||
const refreshTags = useStore((store) => store.refresh);
|
||||
const tags = useStore((store) => store.tags);
|
||||
|
||||
useEffect(() => {
|
||||
refreshTags();
|
||||
}, [refreshTags]);
|
||||
|
||||
const [selected, setSelected] = useState<SelectedReference[]>([]);
|
||||
|
||||
const getAllTags = useCallback(() => {
|
||||
refreshTags();
|
||||
return (store.get().tags as Item[]).filter((a) => a.type !== "header");
|
||||
}, [refreshTags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tags) return;
|
||||
|
||||
setSelected((s) => {
|
||||
const selected = s.slice();
|
||||
for (const tag of tags as Tag[]) {
|
||||
if (tag.type === "header") continue;
|
||||
if (selected.findIndex((a) => a.id === tag.id) > -1) continue;
|
||||
if (tagHasNotes(tag, noteIds)) {
|
||||
selected.push({
|
||||
id: tag.id,
|
||||
op: "add",
|
||||
new: false
|
||||
});
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
});
|
||||
}, [noteIds, tags, setSelected]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={"Add tags"}
|
||||
description={`Add tags to multiple notes at once`}
|
||||
onClose={() => onClose(false)}
|
||||
width={450}
|
||||
positiveButton={{
|
||||
text: "Done",
|
||||
onClick: async () => {
|
||||
for (const id of noteIds) {
|
||||
for (const item of selected) {
|
||||
if (item.op === "add") await db.notes?.note(id).tag(item.id);
|
||||
else await db.notes?.note(id).untag(item.id);
|
||||
}
|
||||
}
|
||||
notestore.refresh();
|
||||
onClose(true);
|
||||
}
|
||||
}}
|
||||
negativeButton={{
|
||||
text: "Cancel",
|
||||
onClick: () => onClose(false)
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
mt={1}
|
||||
sx={{ overflowY: "hidden", flexDirection: "column" }}
|
||||
data-test-id="tag-list"
|
||||
>
|
||||
<FilteredList
|
||||
items={getAllTags}
|
||||
placeholders={{
|
||||
empty: "Add a new tag",
|
||||
filter: "Search or add a new tag"
|
||||
}}
|
||||
filter={(tags, query) => db.lookup?.tags(tags, query) || []}
|
||||
onCreateNewItem={async (title) => {
|
||||
const tag = await db.tags?.add(title);
|
||||
setSelected((selected) => [
|
||||
...selected,
|
||||
{ id: tag.id, new: true, op: "add" }
|
||||
]);
|
||||
}}
|
||||
renderItem={(tag, _index) => {
|
||||
const selectedTag = selected.find((item) => item.id === tag.id);
|
||||
return (
|
||||
<TagItem
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
selected={selectedTag ? selectedTag.op : false}
|
||||
onSelect={() => {
|
||||
setSelected((selected) => {
|
||||
const copy = selected.slice();
|
||||
const index = copy.findIndex((item) => item.id === tag.id);
|
||||
const isNew = copy[index] && copy[index].new;
|
||||
if (isNew) {
|
||||
copy.splice(index, 1);
|
||||
} else if (index > -1) {
|
||||
copy[index] = {
|
||||
...copy[index],
|
||||
op: copy[index].op === "add" ? "remove" : "add"
|
||||
};
|
||||
} else {
|
||||
copy.push({ id: tag.id, new: true, op: "add" });
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TagItem(props: {
|
||||
tag: Item;
|
||||
selected: boolean | SelectedReference["op"];
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { tag, selected, onSelect } = props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="li"
|
||||
data-test-id="tag"
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
bg: "bgSecondary",
|
||||
borderRadius: "default",
|
||||
p: 1,
|
||||
background: "bgSecondary"
|
||||
}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<SelectedCheck size={20} selected={selected} />
|
||||
<Text
|
||||
className="title"
|
||||
data-test-id="notebook-title"
|
||||
variant="subtitle"
|
||||
sx={{ fontWeight: "body", color: "text" }}
|
||||
>
|
||||
#{tag.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddTagsDialog;
|
||||
|
||||
function SelectedCheck({
|
||||
selected,
|
||||
size = 20
|
||||
}: {
|
||||
selected: SelectedReference["op"] | boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
return selected === "add" ? (
|
||||
<Icon.CheckCircleOutline size={size} sx={{ mr: 1 }} color="primary" />
|
||||
) : selected === "remove" ? (
|
||||
<Icon.CheckRemove size={size} sx={{ mr: 1 }} color="error" />
|
||||
) : (
|
||||
<Icon.CircleEmpty size={size} sx={{ mr: 1, opacity: 0.4 }} />
|
||||
);
|
||||
}
|
||||
|
||||
function tagHasNotes(tag: Tag, noteIds: string[]) {
|
||||
return tag.noteIds.some((id) => noteIds.indexOf(id) > -1);
|
||||
}
|
||||
@@ -1,68 +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 } from "react";
|
||||
import { Flex } from "@theme-ui/components";
|
||||
import { getTotalSize } from "../../common/attachments";
|
||||
import { useStore } from "../../stores/attachment-store";
|
||||
import { formatBytes } from "../../utils/filename";
|
||||
import Field from "../field";
|
||||
import ListContainer from "../list-container";
|
||||
import Dialog from "./dialog";
|
||||
import Placeholder from "../placeholders";
|
||||
|
||||
function AttachmentsDialog({ onClose }) {
|
||||
const attachments = useStore((store) => store.attachments);
|
||||
const refresh = useStore((store) => store.refresh);
|
||||
const filter = useStore((store) => store.filter);
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
width={500}
|
||||
title="Attachments"
|
||||
description={`${attachments.length} attachments | ${formatBytes(
|
||||
getTotalSize(attachments)
|
||||
)} occupied`}
|
||||
onClose={onClose}
|
||||
noScroll
|
||||
negativeButton={{ text: "Close", onClick: onClose }}
|
||||
>
|
||||
<Flex px={2} sx={{ flexDirection: "column", height: 500 }}>
|
||||
<Field
|
||||
placeholder="Filter attachments by filename, type or hash"
|
||||
sx={{ mb: 1, px: 1 }}
|
||||
onChange={(e) => filter(e.target.value)}
|
||||
/>
|
||||
<ListContainer
|
||||
header={<div />}
|
||||
type="attachments"
|
||||
groupType="attachments"
|
||||
placeholder={<Placeholder context="attachments" />}
|
||||
items={attachments}
|
||||
/>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttachmentsDialog;
|
||||
515
apps/web/src/components/dialogs/attachments-dialog.tsx
Normal file
515
apps/web/src/components/dialogs/attachments-dialog.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
/*
|
||||
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, memo, useMemo, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Donut,
|
||||
Flex,
|
||||
Input,
|
||||
Label,
|
||||
Text
|
||||
} from "@theme-ui/components";
|
||||
import { getTotalSize } from "../../common/attachments";
|
||||
import { useStore, store } from "../../stores/attachment-store";
|
||||
import { formatBytes } from "../../utils/filename";
|
||||
import Dialog from "./dialog";
|
||||
import { TableVirtuoso } from "react-virtuoso";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Close,
|
||||
DoubleCheckmark,
|
||||
Download,
|
||||
FileDocument,
|
||||
FileGeneral,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
Icon,
|
||||
Trash,
|
||||
Unlink,
|
||||
Uploading
|
||||
} from "../icons";
|
||||
import NavigationItem from "../navigation-menu/navigation-item";
|
||||
import { pluralize } from "../../utils/string";
|
||||
import { db } from "../../common/db";
|
||||
import { Perform } from "../../common/dialog-controller";
|
||||
import { Multiselect } from "../../common/multi-select";
|
||||
import { CustomScrollbarsVirtualList } from "../list-container";
|
||||
import { Attachment } from "../attachment";
|
||||
import { isDocument, isImage, isVideo } from "@notesnook/core/utils/filename";
|
||||
|
||||
type ToolbarAction = {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
onClick: ({ selected }: { selected: any[] }) => void;
|
||||
};
|
||||
|
||||
const TOOLBAR_ACTIONS: ToolbarAction[] = [
|
||||
{
|
||||
title: "Download",
|
||||
icon: Download,
|
||||
onClick: async ({ selected }) => {
|
||||
await store.get().download(selected);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Recheck",
|
||||
icon: DoubleCheckmark,
|
||||
onClick: async ({ selected }) => {
|
||||
await store.recheck(selected.map((a) => a.metadata.hash));
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Delete",
|
||||
icon: Trash,
|
||||
onClick: ({ selected }) => Multiselect.deleteAttachments(selected)
|
||||
}
|
||||
];
|
||||
|
||||
type AttachmentsDialogProps = { onClose: Perform };
|
||||
function AttachmentsDialog({ onClose }: AttachmentsDialogProps) {
|
||||
const allAttachments = useStore((store) => store.attachments);
|
||||
const [attachments, setAttachments] = useState<any[]>(allAttachments);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState({ id: "name", direction: "asc" });
|
||||
const currentRoute = useRef<Route>("all");
|
||||
const refresh = useStore((store) => store.refresh);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
setAttachments(filterAttachments(currentRoute.current, allAttachments));
|
||||
}, [allAttachments]);
|
||||
|
||||
useEffect(() => {
|
||||
setAttachments((a) => {
|
||||
const attachments = a.slice();
|
||||
if (sortBy.id === "name") {
|
||||
attachments.sort(
|
||||
sortBy.direction === "asc"
|
||||
? (a, b) => a.metadata.filename.localeCompare(b.metadata.filename)
|
||||
: (a, b) => b.metadata.filename.localeCompare(a.metadata.filename)
|
||||
);
|
||||
} else if (sortBy.id === "size") {
|
||||
attachments.sort(
|
||||
sortBy.direction === "asc"
|
||||
? (a, b) => a.length - b.length
|
||||
: (a, b) => b.length - a.length
|
||||
);
|
||||
} else if (sortBy.id === "dateUploaded") {
|
||||
attachments.sort(
|
||||
sortBy.direction === "asc"
|
||||
? (a, b) => a.dateUploaded - b.dateUploaded
|
||||
: (a, b) => b.dateUploaded - a.dateUploaded
|
||||
);
|
||||
}
|
||||
return attachments;
|
||||
});
|
||||
}, [sortBy]);
|
||||
|
||||
const totalSize = useMemo(
|
||||
() => getTotalSize(allAttachments),
|
||||
[allAttachments]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
width={"70%"}
|
||||
onClose={() => onClose(false)}
|
||||
noScroll
|
||||
sx={{ bg: "transparent" }}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
height: "80vw"
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
totalSize={totalSize}
|
||||
filter={(query) => {
|
||||
setAttachments(
|
||||
db.lookup?.attachments(db.attachments?.all || [], query) || []
|
||||
);
|
||||
}}
|
||||
counts={getCounts(allAttachments)}
|
||||
onRouteChange={(route) => {
|
||||
currentRoute.current = route;
|
||||
setSelected([]);
|
||||
setAttachments(filterAttachments(route, allAttachments));
|
||||
}}
|
||||
/>
|
||||
<Flex
|
||||
variant="columnFill"
|
||||
sx={{
|
||||
bg: "background",
|
||||
flexDirection: "column",
|
||||
px: 4,
|
||||
pt: 2,
|
||||
overflowY: "hidden",
|
||||
overflow: "hidden",
|
||||
table: { width: "100%", tableLayout: "fixed" },
|
||||
"tbody::before": {
|
||||
content: `''`,
|
||||
display: "block",
|
||||
height: 5
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ justifyContent: "space-between" }}>
|
||||
<Flex sx={{ gap: 1 }}>
|
||||
{TOOLBAR_ACTIONS.map((tool) => (
|
||||
<Button
|
||||
variant="tool"
|
||||
key={tool.title}
|
||||
title={tool.title}
|
||||
onClick={() =>
|
||||
tool.onClick({
|
||||
selected: attachments.filter(
|
||||
(a) => selected.indexOf(a.id) > -1
|
||||
)
|
||||
})
|
||||
}
|
||||
disabled={!selected.length}
|
||||
sx={{ bg: "transparent", p: 1 }}
|
||||
>
|
||||
<tool.icon size={18} />
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
{/* <Button
|
||||
variant="tool"
|
||||
sx={{ p: 1, display: "flex" }}
|
||||
onClick={async () => {
|
||||
if (!(await insertAttachment())) return;
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
<Plus size={18} />
|
||||
<Text variant="body" sx={{ ml: 1 }}>
|
||||
Upload
|
||||
</Text>
|
||||
</Button> */}
|
||||
</Flex>
|
||||
<TableVirtuoso
|
||||
components={{
|
||||
Scroller: CustomScrollbarsVirtualList,
|
||||
TableRow: (props) => {
|
||||
const attachment = attachments[props["data-item-index"]];
|
||||
return (
|
||||
<Attachment
|
||||
{...props}
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
isSelected={selected.indexOf(attachment.id) > -1}
|
||||
onSelected={() => {
|
||||
setSelected((s) => {
|
||||
const copy = s.slice();
|
||||
const index = copy.indexOf(attachment.id);
|
||||
if (index > -1) copy.splice(index, 1);
|
||||
else copy.push(attachment.id);
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{ height: "100%" }}
|
||||
data={attachments}
|
||||
fixedItemHeight={30}
|
||||
defaultItemHeight={30}
|
||||
fixedHeaderContent={() => (
|
||||
<Box
|
||||
as="tr"
|
||||
sx={{
|
||||
height: 40,
|
||||
th: { borderBottom: "1px solid var(--border)" },
|
||||
bg: "background"
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="th"
|
||||
variant="body"
|
||||
sx={{
|
||||
width: 24,
|
||||
textAlign: "left",
|
||||
fontWeight: "normal",
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Label>
|
||||
<Checkbox
|
||||
sx={{ width: 18, height: 18 }}
|
||||
onChange={(e) => {
|
||||
setSelected(
|
||||
e.currentTarget.checked
|
||||
? attachments.map((a) => a.id)
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</Text>
|
||||
{[
|
||||
{ id: "name", title: "Name", width: "65%" },
|
||||
{ id: "status", width: "24px" },
|
||||
{ id: "size", title: "Size", width: "15%" },
|
||||
{ id: "dateUploaded", title: "Date uploaded", width: "20%" }
|
||||
].map((column) =>
|
||||
!column.title ? (
|
||||
<th key={column.id} />
|
||||
) : (
|
||||
<Box
|
||||
as="th"
|
||||
key={column.id}
|
||||
sx={{
|
||||
width: column.width,
|
||||
cursor: "pointer",
|
||||
px: 1,
|
||||
mb: 2,
|
||||
":hover": { bg: "hover" }
|
||||
}}
|
||||
onClick={() => {
|
||||
setSortBy((sortBy) => ({
|
||||
direction:
|
||||
sortBy.id === column.id &&
|
||||
sortBy.direction === "asc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
id: column.id
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{ textAlign: "left", fontWeight: "normal" }}
|
||||
>
|
||||
{column.title}
|
||||
</Text>
|
||||
{sortBy.id === column.id ? (
|
||||
sortBy.direction === "asc" ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)
|
||||
) : null}
|
||||
</Flex>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
itemContent={() => <></>}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttachmentsDialog;
|
||||
|
||||
type Route = "all" | "images" | "documents" | "videos" | "uploads" | "orphaned";
|
||||
|
||||
const routes: { id: Route; icon: Icon; title: string }[] = [
|
||||
{
|
||||
id: "all",
|
||||
icon: FileGeneral,
|
||||
title: "All files"
|
||||
},
|
||||
{
|
||||
id: "images",
|
||||
icon: FileImage,
|
||||
title: "Images"
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
icon: FileDocument,
|
||||
title: "Documents"
|
||||
},
|
||||
{
|
||||
id: "videos",
|
||||
icon: FileVideo,
|
||||
title: "Videos"
|
||||
},
|
||||
{
|
||||
id: "uploads",
|
||||
icon: Uploading,
|
||||
title: "Uploads"
|
||||
},
|
||||
{
|
||||
id: "orphaned",
|
||||
icon: Unlink,
|
||||
title: "Orphaned"
|
||||
}
|
||||
];
|
||||
|
||||
type SidebarProps = {
|
||||
onRouteChange: (route: Route) => void;
|
||||
filter: (query: string) => void;
|
||||
counts: Record<Route, number>;
|
||||
totalSize: number;
|
||||
};
|
||||
const Sidebar = memo(
|
||||
function Sidebar(props: SidebarProps) {
|
||||
const { onRouteChange, filter, counts, totalSize } = props;
|
||||
const [route, setRoute] = useState("all");
|
||||
const downloadStatus = useStore((store) => store.status);
|
||||
const cancelDownload = useStore((store) => store.cancel);
|
||||
const download = useStore((store) => store.download);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
width: 240,
|
||||
bg: "bgSecondary",
|
||||
"@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none))":
|
||||
{
|
||||
bg: "bgTransparent",
|
||||
backdropFilter: "blur(8px)"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
sx={{ m: 2, mb: 0, width: "auto", bg: "bgSecondary", py: "7px" }}
|
||||
onChange={(e) => {
|
||||
setRoute(e.target.value ? "none" : "all");
|
||||
if (e.target.value) filter(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{routes.map((item) => (
|
||||
<NavigationItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
count={counts[item.id]}
|
||||
onClick={() => {
|
||||
onRouteChange(item.id);
|
||||
setRoute(item.id);
|
||||
}}
|
||||
selected={route === item.id}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Flex sx={{ pl: 2, m: 2, mt: 1, justifyContent: "space-between" }}>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Text variant="body">
|
||||
{pluralize(counts.all, "file", "files")}
|
||||
</Text>
|
||||
<Text variant="subBody">{formatBytes(totalSize)}</Text>
|
||||
</Flex>
|
||||
<Button
|
||||
variant="tool"
|
||||
sx={{
|
||||
bg: "transparent",
|
||||
borderRadius: 100,
|
||||
position: "relative",
|
||||
width: 38,
|
||||
height: 38
|
||||
}}
|
||||
title="Download all attachments"
|
||||
onClick={async () => {
|
||||
if (downloadStatus) {
|
||||
await cancelDownload();
|
||||
} else {
|
||||
await download(db.attachments?.all);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{downloadStatus ? <Close size={18} /> : <Download size={18} />}
|
||||
{downloadStatus ? (
|
||||
<Donut
|
||||
value={(downloadStatus.current / downloadStatus.total) * 100}
|
||||
max={100}
|
||||
size={38}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.totalSize === next.totalSize &&
|
||||
prev.counts.all === next.counts.all &&
|
||||
prev.counts.documents === next.counts.documents &&
|
||||
prev.counts.images === next.counts.images &&
|
||||
prev.counts.videos === next.counts.videos &&
|
||||
prev.counts.uploads === next.counts.uploads &&
|
||||
prev.counts.orphaned === next.counts.orphaned
|
||||
);
|
||||
|
||||
function getCounts(attachments: any[]): Record<Route, number> {
|
||||
const counts: Record<Route, number> = {
|
||||
all: 0,
|
||||
documents: 0,
|
||||
images: 0,
|
||||
videos: 0,
|
||||
uploads: 0,
|
||||
orphaned: 0
|
||||
};
|
||||
for (const attachment of attachments) {
|
||||
counts.all++;
|
||||
|
||||
if (isDocument(attachment.metadata.type)) counts.documents++;
|
||||
else if (isImage(attachment.metadata.type)) counts.images++;
|
||||
else if (isVideo(attachment.metadata.type)) counts.videos++;
|
||||
|
||||
if (!attachment.dateUploaded) counts.uploads++;
|
||||
if (!attachment.noteIds.length) counts.orphaned++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function filterAttachments(route: Route, attachments: any[]): any[] {
|
||||
return route === "all"
|
||||
? attachments
|
||||
: route === "images"
|
||||
? attachments.filter((a) => a.metadata.type.startsWith("image/"))
|
||||
: route === "videos"
|
||||
? attachments.filter((a) => a.metadata.type.startsWith("video/"))
|
||||
: route === "documents"
|
||||
? attachments.filter((a) => isDocument(a.metadata.type))
|
||||
: route === "orphaned"
|
||||
? attachments.filter((a) => !a.noteIds.length)
|
||||
: attachments.filter((a) => !a.dateUploaded);
|
||||
}
|
||||
145
apps/web/src/components/dialogs/billing-history-dialog.tsx
Normal file
145
apps/web/src/components/dialogs/billing-history-dialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Perform } from "../../common/dialog-controller";
|
||||
import Dialog from "./dialog";
|
||||
import { db } from "../../common/db";
|
||||
import { Loading } from "../icons";
|
||||
import { Flex, Link, Text } from "@theme-ui/components";
|
||||
import { formatDate } from "@notesnook/core/utils/date";
|
||||
|
||||
type Transaction = {
|
||||
order_id: string;
|
||||
checkout_id: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: keyof typeof TransactionStatusToText;
|
||||
created_at: Date;
|
||||
passthrough: null;
|
||||
product_id: number;
|
||||
is_subscription: boolean;
|
||||
is_one_off: boolean;
|
||||
subscription: Subscription;
|
||||
user: User;
|
||||
receipt_url: string;
|
||||
};
|
||||
|
||||
type Subscription = {
|
||||
subscription_id: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type User = {
|
||||
user_id: number;
|
||||
email: string;
|
||||
marketing_consent: boolean;
|
||||
};
|
||||
|
||||
const TransactionStatusToText = {
|
||||
completed: "Completed",
|
||||
refunded: "Refunded",
|
||||
partially_refunded: "Partially refunded",
|
||||
disputed: "Disputed"
|
||||
};
|
||||
|
||||
export type BillingHistoryDialogProps = {
|
||||
onClose: Perform;
|
||||
};
|
||||
|
||||
export default function BillingHistoryDialog(props: BillingHistoryDialogProps) {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
try {
|
||||
setError(undefined);
|
||||
setIsLoading(true);
|
||||
|
||||
const transactions = await db.subscriptions?.transactions();
|
||||
if (!transactions) return;
|
||||
setTransactions(transactions);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) setError(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={"Billing history"}
|
||||
description={"View all the transactions you have made with Notesnook."}
|
||||
onClose={() => props.onClose(false)}
|
||||
negativeButton={{ text: "Close", onClick: () => props.onClose(false) }}
|
||||
width={600}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loading sx={{ mt: 2 }} />
|
||||
) : error ? (
|
||||
<Flex sx={{ bg: "errorBg", p: 1, borderRadius: "default" }}>
|
||||
<Text variant="error">
|
||||
{error.message}
|
||||
<br />
|
||||
{error.stack}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex sx={{ flexDirection: "column", gap: 1, mt: 1 }}>
|
||||
{transactions.map((transaction) => (
|
||||
<Flex
|
||||
key={transaction.order_id}
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
pb: 1
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Text variant="subtitle">Order #{transaction.order_id}</Text>
|
||||
<Text variant="body" sx={{ color: "fontTertiary" }}>
|
||||
{formatDate(new Date(transaction.created_at).getTime())} •{" "}
|
||||
{TransactionStatusToText[transaction.status]}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex sx={{ flexDirection: "column", alignItems: "end" }}>
|
||||
<Text variant="body">
|
||||
{transaction.amount} {transaction.currency}
|
||||
</Text>
|
||||
<Link
|
||||
href={transaction.receipt_url}
|
||||
target="_blank"
|
||||
rel="noreferer nofollow"
|
||||
variant="text.subBody"
|
||||
sx={{ color: "primary" }}
|
||||
>
|
||||
View receipt
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,8 @@ export function BuyDialog(props: BuyDialogProps) {
|
||||
const theme = useTheme() as Theme;
|
||||
|
||||
const onApplyCoupon = useCheckoutStore((store) => store.applyCoupon);
|
||||
const isCheckoutCompleted = useCheckoutStore((store) => store.isCompleted);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useCheckoutStore.getState().reset();
|
||||
@@ -118,7 +120,7 @@ export function BuyDialog(props: BuyDialogProps) {
|
||||
boxShadow: "4px 5px 18px 2px #00000038",
|
||||
borderRadius: "dialog",
|
||||
flexDirection: ["column", "column", "row"],
|
||||
width: ["95%", "80%", "60%"],
|
||||
width: ["95%", "80%", isCheckoutCompleted ? "400px" : "60%"],
|
||||
maxHeight: ["95%", "80%", "80%"],
|
||||
alignSelf: "center",
|
||||
overflowY: ["scroll", "scroll", "hidden"]
|
||||
@@ -139,7 +141,7 @@ export function BuyDialog(props: BuyDialogProps) {
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: ["100%", "100%", 350]
|
||||
width: ["100%", "100%", isCheckoutCompleted ? "100%" : 350]
|
||||
}}
|
||||
p={4}
|
||||
py={50}
|
||||
@@ -165,6 +167,9 @@ function SideBar(props: SideBarProps) {
|
||||
const user = useUserStore((store) => store.user);
|
||||
const couponCode = useCheckoutStore((store) => store.couponCode);
|
||||
const onApplyCoupon = useCheckoutStore((store) => store.applyCoupon);
|
||||
const isCheckoutCompleted = useCheckoutStore((store) => store.isCompleted);
|
||||
|
||||
if (isCheckoutCompleted) return <CheckoutCompleted onClose={onClose} />;
|
||||
|
||||
if (user && selectedPlan)
|
||||
return (
|
||||
@@ -230,12 +235,16 @@ function Details() {
|
||||
const user = useUserStore((store) => store.user);
|
||||
const selectedPlan = useCheckoutStore((state) => state.selectedPlan);
|
||||
const onPriceUpdated = useCheckoutStore((state) => state.updatePrice);
|
||||
const completeCheckout = useCheckoutStore((state) => state.completeCheckout);
|
||||
const isCheckoutCompleted = useCheckoutStore((store) => store.isCompleted);
|
||||
const couponCode = useCheckoutStore((store) => store.couponCode);
|
||||
const setIsApplyingCoupon = useCheckoutStore(
|
||||
(store) => store.setIsApplyingCoupon
|
||||
);
|
||||
const theme = useThemeStore((store) => store.theme);
|
||||
|
||||
if (isCheckoutCompleted) return null;
|
||||
|
||||
if (selectedPlan && user)
|
||||
return (
|
||||
<PaddleCheckout
|
||||
@@ -243,6 +252,7 @@ function Details() {
|
||||
theme={theme}
|
||||
user={user}
|
||||
coupon={couponCode}
|
||||
onCompleted={completeCheckout}
|
||||
onCouponApplied={() => setIsApplyingCoupon(true)}
|
||||
onPriceUpdated={(pricingInfo) => {
|
||||
onPriceUpdated(pricingInfo);
|
||||
@@ -396,6 +406,31 @@ function AlreadyPremium(props: AlreadyPremiumProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutCompleted(props: { onClose: () => void }) {
|
||||
const { onClose } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rocket width={200} />
|
||||
<Text variant="heading" mt={4} sx={{ textAlign: "center" }}>
|
||||
Thank you!
|
||||
</Text>
|
||||
<Text variant="body" mt={1} sx={{ textAlign: "center" }}>
|
||||
You have successfully subscribed to Notesnook Pro.
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
mt={2}
|
||||
sx={{ borderRadius: 100, px: 6 }}
|
||||
onClick={onClose}
|
||||
data-test-id="see-all-plans"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SelectedPlanProps = {
|
||||
plan: Plan;
|
||||
pricingInfo: PricingInfo | undefined;
|
||||
|
||||
@@ -33,13 +33,25 @@ import {
|
||||
|
||||
// const isDev = false; // process.env.NODE_ENV === "development";
|
||||
// const VENDOR_ID = isDev ? 1506 : 128190;
|
||||
const PADDLE_ORIGIN = "https://buy.paddle.com";
|
||||
const PADDLE_ORIGIN =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "https://sandbox-buy.paddle.com"
|
||||
: "https://buy.paddle.com";
|
||||
const CHECKOUT_CREATE_ORIGIN =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "https://sandbox-create-checkout.paddle.com"
|
||||
: "https://create-checkout.paddle.com";
|
||||
const CHECKOUT_SERVICE_ORIGIN =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "https://sandbox-checkout-service.paddle.com"
|
||||
: "https://checkout-service.paddle.com";
|
||||
|
||||
const SUBSCRIBED_EVENTS: PaddleEvents[] = [
|
||||
PaddleEvents["Checkout.Loaded"],
|
||||
PaddleEvents["Checkout.Coupon.Applied"],
|
||||
PaddleEvents["Checkout.Coupon.Remove"],
|
||||
PaddleEvents["Checkout.Location.Submit"]
|
||||
PaddleEvents["Checkout.Location.Submit"],
|
||||
PaddleEvents["Checkout.Complete"]
|
||||
];
|
||||
|
||||
type PaddleCheckoutProps = {
|
||||
@@ -48,10 +60,11 @@ type PaddleCheckoutProps = {
|
||||
plan: Plan;
|
||||
onPriceUpdated?: (pricingInfo: PricingInfo) => void;
|
||||
onCouponApplied?: () => void;
|
||||
onCompleted?: () => void;
|
||||
coupon?: string;
|
||||
};
|
||||
export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
const { plan, onPriceUpdated, coupon, onCouponApplied } = props;
|
||||
const { plan, onPriceUpdated, coupon, onCouponApplied, onCompleted } = props;
|
||||
const [sourceUrl, setSourceUrl] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [checkoutId, setCheckoutId] = useState<string>();
|
||||
@@ -61,7 +74,7 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
const reloadCheckout = useCallback(() => {
|
||||
if (!checkoutRef.current) return;
|
||||
setIsLoading(true);
|
||||
checkoutRef.current.src = `https://buy.paddle.com/checkout/?checkout_id=${checkoutId}&display_mode=inline&apple_pay_enabled=false`;
|
||||
checkoutRef.current.src = `${PADDLE_ORIGIN}/checkout/?checkout_id=${checkoutId}&display_mode=inline&apple_pay_enabled=false`;
|
||||
}, [checkoutId]);
|
||||
|
||||
const updatePrice = useCallback(
|
||||
@@ -96,6 +109,10 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
)
|
||||
return;
|
||||
|
||||
if (event_name === PaddleEvents["Checkout.Complete"]) {
|
||||
onCompleted && onCompleted();
|
||||
return;
|
||||
}
|
||||
if (event_name === PaddleEvents["Checkout.Loaded"]) setIsLoading(false);
|
||||
|
||||
const pricingInfo = await updatePrice(checkout.id);
|
||||
@@ -108,7 +125,7 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
};
|
||||
}, [onPriceUpdated, updatePrice, plan]);
|
||||
}, [onPriceUpdated, updatePrice, plan, onCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -173,7 +190,7 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
|
||||
async function getCheckoutURL(params: PaddleCheckoutProps) {
|
||||
const { plan, theme, user } = params;
|
||||
const BASE_URL = `https://create-checkout.paddle.com/checkout/product/${plan.id}`;
|
||||
const BASE_URL = `${CHECKOUT_CREATE_ORIGIN}/checkout/product/${plan.id}`;
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("product", plan.id);
|
||||
queryParams.set("passthrough", JSON.stringify({ userId: user.id }));
|
||||
@@ -227,7 +244,7 @@ async function applyCoupon(
|
||||
checkoutId: string,
|
||||
couponCode: string
|
||||
): Promise<CheckoutData | false> {
|
||||
const url = ` https://checkout-service.paddle.com/checkout/${checkoutId}/coupon`;
|
||||
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/coupon`;
|
||||
const body = { data: { coupon_code: couponCode } };
|
||||
const headers = new Headers();
|
||||
headers.set("content-type", "application/json");
|
||||
@@ -246,7 +263,7 @@ async function applyCoupon(
|
||||
}
|
||||
|
||||
async function removeCoupon(checkoutId: string): Promise<CheckoutData | false> {
|
||||
const url = ` https://checkout-service.paddle.com/checkout/${checkoutId}/coupon`;
|
||||
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/coupon`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE"
|
||||
@@ -260,7 +277,7 @@ async function removeCoupon(checkoutId: string): Promise<CheckoutData | false> {
|
||||
}
|
||||
|
||||
async function sendCheckoutEvent(checkoutId: string, eventName: PaddleEvents) {
|
||||
const url = ` https://checkout-service.paddle.com/checkout/${checkoutId}/event`;
|
||||
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/event`;
|
||||
const body = { data: { event_name: eventName } };
|
||||
const headers = new Headers();
|
||||
headers.set("content-type", "application/json");
|
||||
@@ -275,7 +292,7 @@ async function sendCheckoutEvent(checkoutId: string, eventName: PaddleEvents) {
|
||||
async function getCheckoutData(
|
||||
checkoutId: string
|
||||
): Promise<CheckoutData | undefined> {
|
||||
const url = `https://checkout-service.paddle.com/checkout/${checkoutId}`;
|
||||
const url = `${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as CheckoutDataResponse;
|
||||
|
||||
@@ -32,7 +32,7 @@ export const DEFAULT_PLANS: Plan[] = [
|
||||
country: "PK",
|
||||
currency: "USD",
|
||||
discount: 0,
|
||||
id: "648884",
|
||||
id: process.env.NODE_ENV === "development" ? "9822" : "648884",
|
||||
price: { gross: 4.49, net: 0, tax: 0 }
|
||||
},
|
||||
{
|
||||
@@ -40,7 +40,7 @@ export const DEFAULT_PLANS: Plan[] = [
|
||||
country: "PK",
|
||||
currency: "USD",
|
||||
discount: 0,
|
||||
id: "658759",
|
||||
id: process.env.NODE_ENV === "development" ? "50305" : "658759",
|
||||
price: { gross: 49.99, net: 0, tax: 0 }
|
||||
}
|
||||
];
|
||||
@@ -52,7 +52,8 @@ export const PLAN_METADATA: Record<Period, PlanMetadata> = {
|
||||
|
||||
let CACHED_PLANS: Plan[];
|
||||
export async function getPlans(): Promise<Plan[] | null> {
|
||||
if (isTesting()) return DEFAULT_PLANS;
|
||||
if (isTesting() || process.env.NODE_ENV === "development")
|
||||
return DEFAULT_PLANS;
|
||||
if (CACHED_PLANS) return CACHED_PLANS;
|
||||
|
||||
const url = `https://notesnook.com/api/v1/prices/products/web`;
|
||||
|
||||
@@ -22,6 +22,8 @@ import create from "zustand";
|
||||
import produce from "immer";
|
||||
|
||||
interface ICheckoutStore {
|
||||
isCompleted: boolean;
|
||||
completeCheckout: () => void;
|
||||
selectedPlan?: Plan;
|
||||
selectPlan: (plan?: Plan) => void;
|
||||
pricingInfo?: PricingInfo;
|
||||
@@ -33,10 +35,17 @@ interface ICheckoutStore {
|
||||
reset: () => void;
|
||||
}
|
||||
export const useCheckoutStore = create<ICheckoutStore>((set) => ({
|
||||
isCompleted: false,
|
||||
selectedPlan: undefined,
|
||||
pricingInfo: undefined,
|
||||
couponCode: undefined,
|
||||
isApplyingCoupon: false,
|
||||
completeCheckout: () =>
|
||||
set(
|
||||
produce((state: ICheckoutStore) => {
|
||||
state.isCompleted = true;
|
||||
})
|
||||
),
|
||||
selectPlan: (plan) =>
|
||||
set(
|
||||
produce((state: ICheckoutStore) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Flex, Text, Button, ButtonProps } from "@theme-ui/components";
|
||||
import * as Icon from "../icons";
|
||||
import ReactModal from "react-modal";
|
||||
import { FlexScrollContainer } from "../scroll-container";
|
||||
import { SxProp } from "@theme-ui/core";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
|
||||
@@ -32,7 +33,7 @@ type DialogButtonProps = ButtonProps & {
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type DialogProps = {
|
||||
type DialogProps = SxProp & {
|
||||
isOpen?: boolean;
|
||||
onClose?: (
|
||||
event?: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<Element>
|
||||
@@ -47,6 +48,7 @@ type DialogProps = {
|
||||
positiveButton?: DialogButtonProps | null;
|
||||
negativeButton?: DialogButtonProps | null;
|
||||
footer?: React.Component;
|
||||
noScroll?: boolean;
|
||||
};
|
||||
|
||||
function BaseDialog(props: React.PropsWithChildren<DialogProps>) {
|
||||
@@ -94,7 +96,9 @@ function BaseDialog(props: React.PropsWithChildren<DialogProps>) {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
boxShadow: "4px 5px 18px 2px #00000038",
|
||||
borderRadius: "dialog"
|
||||
borderRadius: "dialog",
|
||||
|
||||
...props.sx
|
||||
}}
|
||||
>
|
||||
{props.showCloseButton && (
|
||||
@@ -111,34 +115,42 @@ function BaseDialog(props: React.PropsWithChildren<DialogProps>) {
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
)}
|
||||
<Flex sx={{ flexDirection: "column" }} p={4} pb={0}>
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: "subheading",
|
||||
textAlign: props.textAlignment || "left",
|
||||
color: "text"
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</Text>
|
||||
{props.description && (
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
textAlign: props.textAlignment || "left",
|
||||
color: "fontTertiary"
|
||||
}}
|
||||
>
|
||||
{props.description}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex variant="columnFill" sx={{ overflowY: "hidden" }} my={1}>
|
||||
<FlexScrollContainer style={{ paddingRight: 20, paddingLeft: 20 }}>
|
||||
{props.children}
|
||||
</FlexScrollContainer>
|
||||
</Flex>
|
||||
{props.title || props.description ? (
|
||||
<Flex sx={{ flexDirection: "column" }} p={4} pb={0}>
|
||||
{props.title && (
|
||||
<Text
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: "subheading",
|
||||
textAlign: props.textAlignment || "left",
|
||||
color: "text"
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</Text>
|
||||
)}
|
||||
{props.description && (
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
textAlign: props.textAlignment || "left",
|
||||
color: "fontTertiary"
|
||||
}}
|
||||
>
|
||||
{props.description}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
) : null}
|
||||
{props.noScroll ? (
|
||||
<>{props.children}</>
|
||||
) : (
|
||||
<Flex variant="columnFill" sx={{ overflowY: "hidden" }} my={1}>
|
||||
<FlexScrollContainer style={{ paddingRight: 20, paddingLeft: 20 }}>
|
||||
{props.children}
|
||||
</FlexScrollContainer>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{(props.positiveButton || props.negativeButton) && (
|
||||
<Flex
|
||||
|
||||
@@ -22,7 +22,7 @@ import Dialog from "./dialog";
|
||||
import { getHomeRoute, hardNavigate } from "../../navigation";
|
||||
import { appVersion } from "../../utils/version";
|
||||
import Config from "../../utils/config";
|
||||
import { isDesktop, isTesting } from "../../utils/platform";
|
||||
import { isTesting } from "../../utils/platform";
|
||||
import { useEffect } from "react";
|
||||
import { ArrowRight, Checkmark, Icon, Warn } from "../icons";
|
||||
|
||||
@@ -89,9 +89,23 @@ const features: Record<FeatureKeys, Feature> = {
|
||||
)
|
||||
}
|
||||
]
|
||||
: isDesktop()
|
||||
? []
|
||||
: [],
|
||||
: [
|
||||
{
|
||||
title: "Attachments preview",
|
||||
subtitle:
|
||||
"You can now preview PDFs & Images directly inside Notesnook."
|
||||
},
|
||||
{
|
||||
title: "New attachments manager",
|
||||
subtitle:
|
||||
"The new attachments manager makes is much easier to view & interact with your attachments. It also allows you to download all (or some) of your attachments."
|
||||
},
|
||||
{
|
||||
title: "Assign tags to multiple notes",
|
||||
subtitle:
|
||||
"You can now easily tag multiple notes without first opening them."
|
||||
}
|
||||
],
|
||||
cta: {
|
||||
title: "Got it",
|
||||
icon: Checkmark,
|
||||
|
||||
@@ -57,6 +57,10 @@ const EmailChangeDialog = React.lazy(() => import("./email-change-dialog"));
|
||||
const LanguageSelectorDialog = React.lazy(
|
||||
() => import("./language-selector-dialog")
|
||||
);
|
||||
const BillingHistoryDialog = React.lazy(
|
||||
() => import("./billing-history-dialog")
|
||||
);
|
||||
const AddTagsDialog = React.lazy(() => import("./add-tags-dialog"));
|
||||
|
||||
export const Dialogs = {
|
||||
AddNotebookDialog,
|
||||
@@ -85,5 +89,7 @@ export const Dialogs = {
|
||||
AddReminderDialog,
|
||||
ReminderPreviewDialog,
|
||||
EmailChangeDialog,
|
||||
LanguageSelectorDialog
|
||||
LanguageSelectorDialog,
|
||||
BillingHistoryDialog,
|
||||
AddTagsDialog
|
||||
};
|
||||
|
||||
@@ -17,12 +17,11 @@ 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 { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Box, Button, Flex, Input, Text } from "@theme-ui/components";
|
||||
import * as Icon from "../icons";
|
||||
import { db } from "../../common/db";
|
||||
import Dialog from "./dialog";
|
||||
import Field from "../field";
|
||||
import { useStore, store } from "../../stores/notebook-store";
|
||||
import { store as notestore } from "../../stores/note-store";
|
||||
import { Perform } from "../../common/dialog-controller";
|
||||
@@ -30,6 +29,7 @@ import { showToast } from "../../utils/toast";
|
||||
import { pluralize } from "../../utils/string";
|
||||
import { isMac } from "../../utils/platform";
|
||||
import create from "zustand";
|
||||
import { FilteredList } from "../filtered-list";
|
||||
|
||||
type MoveDialogProps = { onClose: Perform; noteIds: string[] };
|
||||
type NotebookReference = {
|
||||
@@ -59,7 +59,7 @@ interface ISelectionStore {
|
||||
export const useSelectionStore = create<ISelectionStore>((set) => ({
|
||||
selected: [],
|
||||
isMultiselect: false,
|
||||
setSelected: (selected) => set({ selected }),
|
||||
setSelected: (selected) => set({ selected: selected.slice() }),
|
||||
setIsMultiselect: (isMultiselect) => set({ isMultiselect })
|
||||
}));
|
||||
|
||||
@@ -193,7 +193,7 @@ function MoveDialog({ onClose, noteIds }: MoveDialogProps) {
|
||||
sx={{ overflowY: "hidden", flexDirection: "column" }}
|
||||
data-test-id="notebook-list"
|
||||
>
|
||||
<FilteredTree
|
||||
<FilteredList
|
||||
placeholders={{
|
||||
empty: "Add a new notebook",
|
||||
filter: "Search or add a new notebook"
|
||||
@@ -423,127 +423,6 @@ function TopicItem(props: { topic: Topic }) {
|
||||
|
||||
export default MoveDialog;
|
||||
|
||||
type FilteredTreeProps<T extends Item> = {
|
||||
placeholders: { filter: string; empty: string };
|
||||
items: () => T[];
|
||||
filter: (items: T[], query: string) => T[];
|
||||
onCreateNewItem: (title: string) => Promise<void>;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
refresh: () => void,
|
||||
isSearching: boolean
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
function FilteredTree<T extends Item>(props: FilteredTreeProps<T>) {
|
||||
const {
|
||||
items: _items,
|
||||
filter,
|
||||
onCreateNewItem,
|
||||
placeholders,
|
||||
renderItem
|
||||
} = props;
|
||||
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [query, setQuery] = useState<string>();
|
||||
const noItemsFound = items.length <= 0 && query && query.length > 0;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setItems(_items());
|
||||
}, [_items]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const _filter = useCallback(
|
||||
(query) => {
|
||||
setItems(() => {
|
||||
const items = _items();
|
||||
if (!query) {
|
||||
return items;
|
||||
}
|
||||
return filter(items, query);
|
||||
});
|
||||
setQuery(query);
|
||||
},
|
||||
[_items, filter]
|
||||
);
|
||||
|
||||
const _createNewItem = useCallback(
|
||||
async (title) => {
|
||||
await onCreateNewItem(title);
|
||||
refresh();
|
||||
setQuery(undefined);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
[inputRef, refresh, onCreateNewItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
inputRef={inputRef}
|
||||
data-test-id={"filter-input"}
|
||||
autoFocus
|
||||
placeholder={
|
||||
items.length <= 0 ? placeholders.empty : placeholders.filter
|
||||
}
|
||||
onChange={(e: ChangeEvent) =>
|
||||
_filter((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onKeyUp={async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && noItemsFound) {
|
||||
await _createNewItem(query);
|
||||
}
|
||||
}}
|
||||
action={
|
||||
items.length <= 0
|
||||
? {
|
||||
icon: Icon.Plus,
|
||||
onClick: async () => await _createNewItem(query)
|
||||
}
|
||||
: { icon: Icon.Search, onClick: () => _filter(query) }
|
||||
}
|
||||
/>
|
||||
<Flex
|
||||
as="ul"
|
||||
mt={1}
|
||||
sx={{
|
||||
overflowY: "hidden",
|
||||
listStyle: "none",
|
||||
m: 0,
|
||||
p: 0,
|
||||
gap: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
{noItemsFound && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
py: 2
|
||||
}}
|
||||
onClick={async () => {
|
||||
await _createNewItem(query);
|
||||
}}
|
||||
>
|
||||
<Text variant={"body"}>{`Add "${query}"`}</Text>
|
||||
<Icon.Plus size={16} color="primary" />
|
||||
</Button>
|
||||
)}
|
||||
{items.map((item, index) => renderItem(item, index, refresh, !!query))}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedCheck({
|
||||
item,
|
||||
size = 20
|
||||
|
||||
@@ -72,7 +72,7 @@ function Header({ readonly }) {
|
||||
}
|
||||
export default Header;
|
||||
|
||||
function Autosuggest({
|
||||
export function Autosuggest({
|
||||
sessionId,
|
||||
filter,
|
||||
onRemove,
|
||||
@@ -200,6 +200,8 @@ function Autosuggest({
|
||||
if (e.key === "ArrowDown" && !text) onOpenMenu(defaultItems.slice());
|
||||
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Tab") {
|
||||
closeMenu();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
useRef,
|
||||
PropsWithChildren
|
||||
} from "react";
|
||||
import { Box, Button, Flex, Text } from "@theme-ui/components";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Box, Button, Flex, Progress, Text } from "@theme-ui/components";
|
||||
import Properties from "../properties";
|
||||
import { useStore, store as editorstore } from "../../stores/editor-store";
|
||||
import {
|
||||
@@ -40,7 +41,7 @@ import Header from "./header";
|
||||
import { Attachment } from "../icons";
|
||||
import { useEditorInstance } from "./context";
|
||||
import { attachFile, AttachmentProgress, insertAttachment } from "./picker";
|
||||
import { downloadAttachment } from "../../common/attachments";
|
||||
import { saveAttachment, downloadAttachment } from "../../common/attachments";
|
||||
import { EV, EVENTS } from "@notesnook/core/common";
|
||||
import { db } from "../../common/db";
|
||||
import useMobile from "../../hooks/use-mobile";
|
||||
@@ -49,6 +50,11 @@ import useTablet from "../../hooks/use-tablet";
|
||||
import Config from "../../utils/config";
|
||||
import { AnimatedFlex } from "../animated";
|
||||
import { EditorLoader } from "../loaders/editor-loader";
|
||||
import { Lightbox } from "../lightbox";
|
||||
import ThemeProviderWrapper from "../theme-provider";
|
||||
import { Allotment } from "allotment";
|
||||
import { PdfPreview } from "../pdf-preview";
|
||||
import { showToast } from "../../utils/toast";
|
||||
|
||||
type PreviewSession = {
|
||||
content: { data: string; type: string };
|
||||
@@ -56,6 +62,11 @@ type PreviewSession = {
|
||||
dateEdited: number;
|
||||
};
|
||||
|
||||
type DocumentPreview = {
|
||||
url?: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
function onEditorChange(noteId: string, sessionId: string, content: string) {
|
||||
if (!content) return;
|
||||
|
||||
@@ -79,7 +90,9 @@ export default function EditorManager({
|
||||
// stored in refs. Update this value to trigger an
|
||||
// update.
|
||||
const [timestamp, setTimestamp] = useState<number>(0);
|
||||
|
||||
const lastSavedTime = useRef<number>(0);
|
||||
const [docPreview, setDocPreview] = useState<DocumentPreview>();
|
||||
|
||||
const previewSession = useRef<PreviewSession>();
|
||||
const [dropRef, overlayRef] = useDragOverlay();
|
||||
@@ -100,7 +113,7 @@ export default function EditorManager({
|
||||
async (item?: Record<string, string | number>) => {
|
||||
if (
|
||||
!item ||
|
||||
lastSavedTime.current >= item.dateEdited ||
|
||||
lastSavedTime.current >= (item.dateEdited as number) ||
|
||||
isPreviewSession ||
|
||||
!appstore.get().isRealtimeSyncEnabled
|
||||
)
|
||||
@@ -185,48 +198,126 @@ export default function EditorManager({
|
||||
openSession(noteId);
|
||||
}, [noteId]);
|
||||
|
||||
return (
|
||||
<Allotment
|
||||
proportionalLayout={true}
|
||||
onDragEnd={(sizes) => {
|
||||
Config.set("editor:panesize", sizes[1]);
|
||||
}}
|
||||
>
|
||||
<Allotment.Pane className="editor-pane">
|
||||
<Flex
|
||||
ref={dropRef}
|
||||
id="editorContainer"
|
||||
sx={{
|
||||
position: "relative",
|
||||
alignSelf: "stretch",
|
||||
overflow: "hidden",
|
||||
flex: 1,
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
{previewSession.current && (
|
||||
<PreviewModeNotice
|
||||
{...previewSession.current}
|
||||
onDiscard={() => openSession(noteId)}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
nonce={timestamp}
|
||||
content={
|
||||
previewSession.current?.content?.data ||
|
||||
editorstore.get().session?.content?.data
|
||||
}
|
||||
onPreviewDocument={(url) => setDocPreview(url)}
|
||||
onContentChange={() => (lastSavedTime.current = Date.now())}
|
||||
options={{
|
||||
readonly: isReadonly || isPreviewSession,
|
||||
onRequestFocus: () => toggleProperties(false),
|
||||
onLoadMedia: loadMedia,
|
||||
focusMode: isFocusMode,
|
||||
isMobile: isMobile || isTablet
|
||||
}}
|
||||
/>
|
||||
|
||||
{arePropertiesVisible && (
|
||||
<Properties
|
||||
onOpenPreviewSession={async (session: PreviewSession) => {
|
||||
previewSession.current = session;
|
||||
setTimestamp(Date.now());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DropZone overlayRef={overlayRef} />
|
||||
</Flex>
|
||||
</Allotment.Pane>
|
||||
{docPreview && (
|
||||
<Allotment.Pane
|
||||
minSize={450}
|
||||
preferredSize={Config.get("editor:panesize", 500)}
|
||||
>
|
||||
{docPreview.url ? (
|
||||
<Flex
|
||||
id="editorSidebar"
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
borderLeft: "1px solid var(--border)",
|
||||
height: "100%"
|
||||
}}
|
||||
>
|
||||
<PdfPreview
|
||||
fileUrl={docPreview.url}
|
||||
hash={docPreview.hash}
|
||||
onClose={() => setDocPreview(undefined)}
|
||||
/>
|
||||
</Flex>
|
||||
) : (
|
||||
<DownloadAttachmentProgress hash={docPreview.hash} />
|
||||
)}
|
||||
</Allotment.Pane>
|
||||
)}
|
||||
</Allotment>
|
||||
);
|
||||
}
|
||||
|
||||
type DownloadAttachmentProgressProps = {
|
||||
hash: string;
|
||||
};
|
||||
function DownloadAttachmentProgress(props: DownloadAttachmentProgressProps) {
|
||||
const { hash } = props;
|
||||
|
||||
const [progress, setProgress] = useState(0);
|
||||
useEffect(() => {
|
||||
const event = AppEventManager.subscribe(
|
||||
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
|
||||
(progress: AttachmentProgress) => {
|
||||
if (progress.hash === hash) {
|
||||
setProgress(Math.round((progress.loaded / progress.total) * 100));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
event.unsubscribe();
|
||||
};
|
||||
}, [hash]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={dropRef}
|
||||
id="editorContainer"
|
||||
sx={{
|
||||
position: "relative",
|
||||
alignSelf: "stretch",
|
||||
overflow: "hidden",
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
{previewSession.current && (
|
||||
<PreviewModeNotice
|
||||
{...previewSession.current}
|
||||
onDiscard={() => openSession(noteId)}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
nonce={timestamp}
|
||||
content={
|
||||
previewSession.current?.content?.data ||
|
||||
editorstore.get().session?.content?.data
|
||||
}
|
||||
onContentChange={() => (lastSavedTime.current = Date.now())}
|
||||
options={{
|
||||
readonly: isReadonly || isPreviewSession,
|
||||
onRequestFocus: () => toggleProperties(false),
|
||||
onLoadMedia: loadMedia,
|
||||
focusMode: isFocusMode,
|
||||
isMobile: isMobile || isTablet
|
||||
}}
|
||||
<Text variant="title">Downloading attachment ({progress}%)</Text>
|
||||
<Progress
|
||||
value={progress}
|
||||
max={100}
|
||||
sx={{ width: ["90%", "35%"], mt: 1 }}
|
||||
/>
|
||||
{arePropertiesVisible && (
|
||||
<Properties
|
||||
onOpenPreviewSession={async (session: PreviewSession) => {
|
||||
previewSession.current = session;
|
||||
setTimestamp(Date.now());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DropZone overlayRef={overlayRef} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -244,9 +335,10 @@ type EditorProps = {
|
||||
nonce?: number;
|
||||
options?: EditorOptions;
|
||||
onContentChange?: () => void;
|
||||
onPreviewDocument?: (preview: DocumentPreview) => void;
|
||||
};
|
||||
export function Editor(props: EditorProps) {
|
||||
const { content, nonce, options, onContentChange } = props;
|
||||
const { content, nonce, options, onContentChange, onPreviewDocument } = props;
|
||||
const { readonly, headless, onLoadMedia, isMobile } = options || {
|
||||
headless: false,
|
||||
readonly: false,
|
||||
@@ -314,9 +406,35 @@ export function Editor(props: EditorProps) {
|
||||
}}
|
||||
onContentChange={onContentChange}
|
||||
onChange={onEditorChange}
|
||||
onDownloadAttachment={(attachment) =>
|
||||
downloadAttachment(attachment.hash)
|
||||
}
|
||||
onDownloadAttachment={(attachment) => saveAttachment(attachment.hash)}
|
||||
onPreviewAttachment={async ({ hash, dataurl }) => {
|
||||
const attachment = db.attachments?.attachment(hash);
|
||||
if (attachment && attachment.metadata.type.startsWith("image/")) {
|
||||
const container = document.getElementById("dialogContainer");
|
||||
if (!(container instanceof HTMLElement)) return;
|
||||
|
||||
dataurl = dataurl || (await downloadAttachment(hash, "base64"));
|
||||
if (!dataurl)
|
||||
return showToast("error", "This image cannot be previewed.");
|
||||
|
||||
ReactDOM.render(
|
||||
<ThemeProviderWrapper>
|
||||
<Lightbox
|
||||
image={dataurl}
|
||||
onClose={() => {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
}}
|
||||
/>
|
||||
</ThemeProviderWrapper>,
|
||||
container
|
||||
);
|
||||
} else if (attachment && onPreviewDocument) {
|
||||
onPreviewDocument({ hash });
|
||||
const blob = await downloadAttachment(hash, "blob");
|
||||
if (!blob) return;
|
||||
onPreviewDocument({ url: URL.createObjectURL(blob), hash });
|
||||
}
|
||||
}}
|
||||
onInsertAttachment={(type) => {
|
||||
const mime = type === "file" ? "*/*" : "image/*";
|
||||
insertAttachment(mime).then((file) => {
|
||||
@@ -408,7 +526,6 @@ function EditorChrome(
|
||||
</AnimatedFlex>
|
||||
</Flex>
|
||||
</FlexScrollContainer>
|
||||
|
||||
{isMobile && (
|
||||
<Box
|
||||
id="editorToolbar"
|
||||
|
||||
@@ -155,7 +155,7 @@ export type AttachmentProgress = {
|
||||
export type Attachment = {
|
||||
hash: string;
|
||||
filename: string;
|
||||
type: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
dataurl?: string;
|
||||
};
|
||||
@@ -206,7 +206,7 @@ async function addAttachment(
|
||||
return {
|
||||
hash: hash,
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
mime: file.type,
|
||||
size: file.size,
|
||||
dataurl
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ type TipTapProps = {
|
||||
onContentChange?: () => void;
|
||||
onInsertAttachment?: (type: AttachmentType) => void;
|
||||
onDownloadAttachment?: (attachment: Attachment) => void;
|
||||
onPreviewAttachment?: (attachment: Attachment) => void;
|
||||
onAttachFile?: (file: File) => void;
|
||||
onFocus?: () => void;
|
||||
content?: string;
|
||||
@@ -109,6 +110,7 @@ function TipTap(props: TipTapProps) {
|
||||
onChange,
|
||||
onInsertAttachment,
|
||||
onDownloadAttachment,
|
||||
onPreviewAttachment,
|
||||
onAttachFile,
|
||||
onContentChange,
|
||||
onFocus = () => {},
|
||||
@@ -253,6 +255,10 @@ function TipTap(props: TipTapProps) {
|
||||
onDownloadAttachment?.(attachment);
|
||||
return true;
|
||||
},
|
||||
onPreviewAttachment(_editor, attachment) {
|
||||
onPreviewAttachment?.(attachment);
|
||||
return true;
|
||||
},
|
||||
onOpenLink: (url) => {
|
||||
window.open(url, "_blank");
|
||||
return true;
|
||||
|
||||
151
apps/web/src/components/filtered-list/index.tsx
Normal file
151
apps/web/src/components/filtered-list/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
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 { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Field from "../field";
|
||||
import { Plus, Search } from "../icons";
|
||||
import { Button, Flex, Text } from "@theme-ui/components";
|
||||
|
||||
type FilterableItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type FilteredListProps<T extends FilterableItem> = {
|
||||
placeholders: { filter: string; empty: string };
|
||||
items: () => T[];
|
||||
filter: (items: T[], query: string) => T[];
|
||||
onCreateNewItem: (title: string) => Promise<void>;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
refresh: () => void,
|
||||
isSearching: boolean
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
export function FilteredList<T extends FilterableItem>(
|
||||
props: FilteredListProps<T>
|
||||
) {
|
||||
const {
|
||||
items: _items,
|
||||
filter,
|
||||
onCreateNewItem,
|
||||
placeholders,
|
||||
renderItem
|
||||
} = props;
|
||||
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [query, setQuery] = useState<string>();
|
||||
const noItemsFound = items.length <= 0 && query && query.length > 0;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setItems(_items());
|
||||
}, [_items]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const _filter = useCallback(
|
||||
(query) => {
|
||||
setItems(() => {
|
||||
const items = _items();
|
||||
if (!query) {
|
||||
return items;
|
||||
}
|
||||
return filter(items, query);
|
||||
});
|
||||
setQuery(query);
|
||||
},
|
||||
[_items, filter]
|
||||
);
|
||||
|
||||
const _createNewItem = useCallback(
|
||||
async (title) => {
|
||||
await onCreateNewItem(title);
|
||||
refresh();
|
||||
setQuery(undefined);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
[inputRef, refresh, onCreateNewItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
inputRef={inputRef}
|
||||
data-test-id={"filter-input"}
|
||||
autoFocus
|
||||
placeholder={
|
||||
items.length <= 0 ? placeholders.empty : placeholders.filter
|
||||
}
|
||||
onChange={(e: ChangeEvent) =>
|
||||
_filter((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onKeyUp={async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && noItemsFound) {
|
||||
await _createNewItem(query);
|
||||
}
|
||||
}}
|
||||
action={
|
||||
items.length <= 0
|
||||
? {
|
||||
icon: Plus,
|
||||
onClick: async () => await _createNewItem(query)
|
||||
}
|
||||
: { icon: Search, onClick: () => _filter(query) }
|
||||
}
|
||||
/>
|
||||
<Flex
|
||||
as="ul"
|
||||
mt={1}
|
||||
sx={{
|
||||
overflowY: "hidden",
|
||||
listStyle: "none",
|
||||
m: 0,
|
||||
p: 0,
|
||||
gap: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
{noItemsFound && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
py: 2
|
||||
}}
|
||||
onClick={async () => {
|
||||
await _createNewItem(query);
|
||||
}}
|
||||
>
|
||||
<Text variant={"body"}>{`Add "${query}"`}</Text>
|
||||
<Plus size={16} color="primary" />
|
||||
</Button>
|
||||
)}
|
||||
{items.map((item, index) => renderItem(item, index, refresh, !!query))}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 { useState } from "react";
|
||||
import { memo } from "react";
|
||||
import MDIIcon from "@mdi/react";
|
||||
import {
|
||||
mdiPlus,
|
||||
@@ -191,7 +191,17 @@ import {
|
||||
mdiNoteMultipleOutline,
|
||||
mdiBookMultipleOutline,
|
||||
mdiArrowTopRight,
|
||||
mdiBookmarkRemoveOutline
|
||||
mdiBookmarkRemoveOutline,
|
||||
mdiFileImageOutline,
|
||||
mdiFileDocumentOutline,
|
||||
mdiFileVideoOutline,
|
||||
mdiWeb,
|
||||
mdiUploadOutline,
|
||||
mdiLinkOff,
|
||||
mdiMagnifyPlusOutline,
|
||||
mdiMagnifyMinusOutline,
|
||||
mdiRotateRight,
|
||||
mdiRotateLeft
|
||||
} from "@mdi/js";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Theme } from "@notesnook/theme";
|
||||
@@ -205,7 +215,7 @@ type MDIIconWrapperProps = {
|
||||
color?: keyof Theme["colors"];
|
||||
rotate?: boolean;
|
||||
};
|
||||
function MDIIconWrapper({
|
||||
function _MDIIconWrapper({
|
||||
title,
|
||||
path,
|
||||
size = 24,
|
||||
@@ -237,12 +247,11 @@ function MDIIconWrapper({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const MDIIconWrapper = memo(_MDIIconWrapper, () => true);
|
||||
|
||||
export type IconProps = FlexProps &
|
||||
MotionProps &
|
||||
Omit<MDIIconWrapperProps, "path"> & {
|
||||
hoverColor?: keyof Theme["colors"];
|
||||
};
|
||||
Omit<MDIIconWrapperProps, "path">;
|
||||
|
||||
export type Icon = {
|
||||
(props: IconProps): JSX.Element;
|
||||
@@ -251,7 +260,6 @@ export type Icon = {
|
||||
|
||||
function createIcon(path: string, rotate = false) {
|
||||
const NNIcon: Icon = function Icon(props) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const { sx, rotate: _rotate = rotate, size, ...restProps } = props;
|
||||
return (
|
||||
<Flex
|
||||
@@ -262,17 +270,13 @@ function createIcon(path: string, rotate = false) {
|
||||
alignItems: "center",
|
||||
flexShrink: 0
|
||||
}}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<MDIIconWrapper
|
||||
title={props.title}
|
||||
path={path}
|
||||
rotate={_rotate}
|
||||
size={size}
|
||||
color={
|
||||
props.hoverColor && isHovering ? props.hoverColor : props.color
|
||||
}
|
||||
color={props.color}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
@@ -455,6 +459,7 @@ export const Reupload = createIcon(mdiProgressUpload);
|
||||
export const Rename = createIcon(mdiFormTextbox);
|
||||
export const Upload = createIcon(mdiCloudOffOutline);
|
||||
export const Uploaded = createIcon(mdiCloudCheckOutline);
|
||||
export const Uploading = createIcon(mdiUploadOutline);
|
||||
export const References = createIcon(mdiVectorLink);
|
||||
export const Codeblock = createIcon(mdiCodeBraces);
|
||||
export const Resize = createIcon(mdiArrowCollapseHorizontal);
|
||||
@@ -479,3 +484,18 @@ export const EditorFullWidth = createIcon(
|
||||
`M4 20q-.825 0-1.412-.587Q2 18.825 2 18V6q0-.825.588-1.412Q3.175 4 4 4h16q.825 0 1.413.588Q22 5.175 22 6v12q0 .825-.587 1.413Q20.825 20 20 20Zm0-2h1V6H4v12Zm3 0h10V6H7Zm12 0h1V6h-1ZM7 6v12Z`
|
||||
);
|
||||
export const Suggestion = createIcon(mdiLightbulbOnOutline);
|
||||
|
||||
export const FileImage = createIcon(mdiFileImageOutline);
|
||||
export const FilePDF = createIcon(
|
||||
`M14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8m4 18V9h-5V4H6v16h12m-7.08-7.69c-.24-.77-.77-3.23.63-3.27c1.4-.04.48 3.12.48 3.12c.39 1.49 2.02 2.56 2.02 2.56c.5-.15 3.35-.48 2.95 1c-.43 1.48-3.5.09-3.5.09c-1.95.14-3.41.66-3.41.66c-1.13 2.11-2.45 3.03-2.99 2.14c-.67-1.11 2.13-2.54 2.13-2.54c1.45-2.35 1.67-3.72 1.69-3.76m.65.84c-.4 1.3-1.2 2.69-1.2 2.69c.85-.34 2.71-.73 2.71-.73c-1.14-1-1.49-1.95-1.51-1.96m3.14 2.17s1.75.65 1.79.39c.07-.27-1.33-.51-1.79-.39m-5.66 1.49c-.77.3-1.51 1.58-1.33 1.58c.18.01.91-.6 1.33-1.58m2.52-5.55c0-.05.43-1.68 0-1.73c-.3-.03-.01 1.69 0 1.73z`
|
||||
);
|
||||
export const FileDocument = createIcon(mdiFileDocumentOutline);
|
||||
export const FileVideo = createIcon(mdiFileVideoOutline);
|
||||
export const FileGeneral = createIcon(mdiFileOutline);
|
||||
export const FileWebClip = createIcon(mdiWeb);
|
||||
export const Unlink = createIcon(mdiLinkOff);
|
||||
export const ZoomIn = createIcon(mdiMagnifyPlusOutline);
|
||||
export const ZoomOut = createIcon(mdiMagnifyMinusOutline);
|
||||
export const RotateCW = createIcon(mdiRotateRight);
|
||||
export const RotateACW = createIcon(mdiRotateLeft);
|
||||
export const Reset = createIcon(mdiRestore);
|
||||
|
||||
416
apps/web/src/components/lightbox/index.tsx
Normal file
416
apps/web/src/components/lightbox/index.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
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 { Button, Flex, Image } from "@theme-ui/components";
|
||||
import React from "react";
|
||||
import {
|
||||
Close,
|
||||
Icon,
|
||||
Loading,
|
||||
Reset,
|
||||
RotateACW,
|
||||
RotateCW,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from "../icons";
|
||||
|
||||
const DEFAULT_ZOOM_STEP = 0.3;
|
||||
const DEFAULT_LARGE_ZOOM = 4;
|
||||
|
||||
function getXY(e: React.MouseEvent | React.TouchEvent) {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
if ("touches" in e && e.touches.length) {
|
||||
x = e.touches[0].pageX;
|
||||
y = e.touches[0].pageY;
|
||||
} else if ("pageX" in e && "pageY" in e) {
|
||||
x = e.pageX;
|
||||
y = e.pageY;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
type Image = { url: string; title?: string };
|
||||
export type LightboxProps = {
|
||||
image?: string;
|
||||
// title?: string;
|
||||
zoomStep?: number;
|
||||
images?: Image[];
|
||||
startIndex?: number;
|
||||
keyboardInteraction?: boolean;
|
||||
doubleClickZoom?: number;
|
||||
// showTitle?: boolean;
|
||||
// buttonAlign?: "flex-end" | "flex-start" | "center";
|
||||
allowZoom?: boolean;
|
||||
allowReset?: boolean;
|
||||
allowRotate?: boolean;
|
||||
onNavigateImage?: (index: number) => void;
|
||||
clickOutsideToExit?: boolean;
|
||||
onClose?: (e: React.MouseEvent | KeyboardEvent) => void;
|
||||
};
|
||||
|
||||
export class Lightbox extends React.Component<LightboxProps> {
|
||||
initX = 0;
|
||||
initY = 0;
|
||||
lastX = 0;
|
||||
lastY = 0;
|
||||
_cont = React.createRef<HTMLDivElement>();
|
||||
state = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1,
|
||||
rotate: 0,
|
||||
loading: true,
|
||||
moving: false,
|
||||
current: this.props?.startIndex ?? 0,
|
||||
multi: this.props?.images?.length ? true : false
|
||||
};
|
||||
|
||||
createTransform = (x: number, y: number, zoom: number, rotate: number) =>
|
||||
`translate3d(${x}px,${y}px,0px) scale(${zoom}) rotate(${rotate}deg)`;
|
||||
|
||||
stopSideEffect = (
|
||||
e: React.KeyboardEvent | React.MouseEvent | KeyboardEvent | MouseEvent
|
||||
) => e.stopPropagation();
|
||||
|
||||
getCurrentImage = () => {
|
||||
if (!this.state.multi) return this.props.image ?? "";
|
||||
return this.props.images?.[this.state.current]?.url ?? "";
|
||||
};
|
||||
|
||||
resetZoom = () => this.setState({ x: 0, y: 0, zoom: 1 });
|
||||
shockZoom = (e: React.MouseEvent) => {
|
||||
const {
|
||||
zoomStep = DEFAULT_ZOOM_STEP,
|
||||
allowZoom = true,
|
||||
doubleClickZoom = DEFAULT_LARGE_ZOOM
|
||||
} = this.props;
|
||||
if (!allowZoom || !doubleClickZoom) return false;
|
||||
this.stopSideEffect(e);
|
||||
if (this.state.zoom > 1) return this.resetZoom();
|
||||
const _z =
|
||||
(zoomStep < 1 ? Math.ceil(doubleClickZoom / zoomStep) : zoomStep) *
|
||||
zoomStep;
|
||||
const _xy = getXY(e);
|
||||
const _cbr = this._cont.current?.getBoundingClientRect?.();
|
||||
if (!_cbr) return false;
|
||||
const _ccx = _cbr.x + _cbr.width / 2;
|
||||
const _ccy = _cbr.y + _cbr.height / 2;
|
||||
const x = (_xy.x - _ccx) * -1 * _z;
|
||||
const y = (_xy.y - _ccy) * -1 * _z;
|
||||
this.setState({ x, y, zoom: _z });
|
||||
};
|
||||
navigateImage = (
|
||||
direction: "next" | "prev",
|
||||
e: React.KeyboardEvent | React.MouseEvent | KeyboardEvent | MouseEvent
|
||||
) => {
|
||||
if (!this.props.images) return;
|
||||
|
||||
this.stopSideEffect(e);
|
||||
let current = 0;
|
||||
switch (direction) {
|
||||
case "next":
|
||||
current = this.state.current + 1;
|
||||
break;
|
||||
case "prev":
|
||||
current = this.state.current - 1;
|
||||
break;
|
||||
}
|
||||
if (current >= this.props.images.length) current = 0;
|
||||
else if (current < 0) current = this.props.images.length - 1;
|
||||
this.setState({ current, x: 0, y: 0, zoom: 1, rotate: 0, loading: true });
|
||||
if (typeof this.props.onNavigateImage === "function") {
|
||||
this.props.onNavigateImage(current);
|
||||
}
|
||||
};
|
||||
startMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (this.state.zoom <= 1) return false;
|
||||
this.setState({ moving: true });
|
||||
const xy = getXY(e);
|
||||
this.initX = xy.x - this.lastX;
|
||||
this.initY = xy.y - this.lastY;
|
||||
};
|
||||
duringMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!this.state.moving) return false;
|
||||
const xy = getXY(e);
|
||||
this.lastX = xy.x - this.initX;
|
||||
this.lastY = xy.y - this.initY;
|
||||
this.setState({
|
||||
x: xy.x - this.initX,
|
||||
y: xy.y - this.initY
|
||||
});
|
||||
};
|
||||
endMove = () => this.setState({ moving: false });
|
||||
applyZoom = (type: "in" | "out" | "reset") => {
|
||||
const { zoomStep = DEFAULT_ZOOM_STEP } = this.props;
|
||||
switch (type) {
|
||||
case "in":
|
||||
this.setState({ zoom: this.state.zoom + zoomStep });
|
||||
break;
|
||||
case "out": {
|
||||
const newZoom = this.state.zoom - zoomStep;
|
||||
if (newZoom < 1) break;
|
||||
else if (newZoom === 1) this.setState({ x: 0, y: 0, zoom: 1 });
|
||||
else this.setState({ zoom: newZoom });
|
||||
break;
|
||||
}
|
||||
case "reset":
|
||||
this.resetZoom();
|
||||
break;
|
||||
}
|
||||
};
|
||||
applyRotate = (type: "cw" | "acw") => {
|
||||
switch (type) {
|
||||
case "cw":
|
||||
this.setState({ rotate: this.state.rotate + 90 });
|
||||
break;
|
||||
case "acw":
|
||||
this.setState({ rotate: this.state.rotate - 90 });
|
||||
break;
|
||||
}
|
||||
};
|
||||
reset = (e: React.MouseEvent | KeyboardEvent) => {
|
||||
this.stopSideEffect(e);
|
||||
this.setState({ x: 0, y: 0, zoom: 1, rotate: 0 });
|
||||
};
|
||||
exit = (e: React.MouseEvent | KeyboardEvent) => {
|
||||
if (typeof this.props.onClose === "function") return this.props.onClose(e);
|
||||
console.error(
|
||||
"No Exit function passed on prop: onClose. Clicking the close button will do nothing"
|
||||
);
|
||||
};
|
||||
shouldShowReset = () =>
|
||||
this.state.x ||
|
||||
this.state.y ||
|
||||
this.state.zoom !== 1 ||
|
||||
this.state.rotate !== 0;
|
||||
canvasClick = (e: React.MouseEvent) => {
|
||||
const { clickOutsideToExit = true } = this.props;
|
||||
if (clickOutsideToExit && this.state.zoom <= 1) return this.exit(e);
|
||||
};
|
||||
keyboardNavigation = (e: KeyboardEvent) => {
|
||||
const { allowZoom = true, allowReset = true } = this.props;
|
||||
const { multi, x, y, zoom } = this.state;
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
if (multi && zoom === 1) this.navigateImage("prev", e);
|
||||
else if (zoom > 1) this.setState({ x: x - 20 });
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (multi && zoom === 1) this.navigateImage("next", e);
|
||||
else if (zoom > 1) this.setState({ x: x + 20 });
|
||||
break;
|
||||
case "ArrowUp":
|
||||
if (zoom > 1) this.setState({ y: y + 20 });
|
||||
break;
|
||||
case "ArrowDown":
|
||||
if (zoom > 1) this.setState({ y: y - 20 });
|
||||
break;
|
||||
case "+":
|
||||
if (allowZoom) this.applyZoom("in");
|
||||
break;
|
||||
case "-":
|
||||
if (allowZoom) this.applyZoom("out");
|
||||
break;
|
||||
case "Escape":
|
||||
if (allowReset && this.shouldShowReset()) this.reset(e);
|
||||
else this.exit(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
componentDidMount() {
|
||||
document.body.classList.add("lb-open-lightbox");
|
||||
const { keyboardInteraction = true } = this.props;
|
||||
if (keyboardInteraction)
|
||||
document.addEventListener("keyup", this.keyboardNavigation);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
document.body.classList.remove("lb-open-lightbox");
|
||||
const { keyboardInteraction = true } = this.props;
|
||||
if (keyboardInteraction)
|
||||
document.removeEventListener("keyup", this.keyboardNavigation);
|
||||
}
|
||||
|
||||
render() {
|
||||
const image = this.getCurrentImage();
|
||||
if (!image) {
|
||||
console.warn("Not showing lightbox because no image(s) was supplied");
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
allowZoom = true,
|
||||
allowRotate = true,
|
||||
allowReset = true,
|
||||
onClose
|
||||
} = this.props;
|
||||
const { x, y, zoom, rotate, multi, loading, moving } = this.state;
|
||||
const _reset = allowReset && this.shouldShowReset();
|
||||
|
||||
const tools: {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
enabled: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
hidden?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
title: "Reset",
|
||||
icon: Reset,
|
||||
enabled: true,
|
||||
hidden: !allowReset,
|
||||
onClick: (e) => this.reset(e)
|
||||
},
|
||||
{
|
||||
title: "Rotate left",
|
||||
icon: RotateACW,
|
||||
enabled: true,
|
||||
hidden: !allowRotate,
|
||||
onClick: () => this.applyRotate("acw")
|
||||
},
|
||||
{
|
||||
title: "Rotate rigth",
|
||||
icon: RotateCW,
|
||||
enabled: true,
|
||||
hidden: !allowRotate,
|
||||
onClick: () => this.applyRotate("cw")
|
||||
},
|
||||
{
|
||||
title: "Zoom out",
|
||||
icon: ZoomOut,
|
||||
enabled: zoom > 1,
|
||||
hidden: !allowZoom,
|
||||
onClick: () => this.applyZoom("out")
|
||||
},
|
||||
{
|
||||
title: "Zoom in",
|
||||
icon: ZoomIn,
|
||||
enabled: true,
|
||||
hidden: !allowZoom,
|
||||
onClick: () => this.applyZoom("in")
|
||||
},
|
||||
{
|
||||
title: "Close",
|
||||
icon: Close,
|
||||
enabled: !!onClose,
|
||||
onClick: (e) => this.exit(e)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
zIndex: 50000,
|
||||
position: "fixed",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
bg: "#000000a1",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
bg="bgSecondary"
|
||||
sx={{
|
||||
borderRadius: "0px 0px 0px 5px",
|
||||
overflow: "hidden",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end"
|
||||
}}
|
||||
>
|
||||
{tools.map((tool) => (
|
||||
<Button
|
||||
data-test-id={tool.title}
|
||||
disabled={!tool.enabled}
|
||||
variant="tool"
|
||||
bg="transparent"
|
||||
title={tool.title}
|
||||
key={tool.title}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
display: [
|
||||
tool.hideOnMobile ? "none" : "flex",
|
||||
tool.hidden ? "none" : "flex"
|
||||
],
|
||||
color: tool.enabled ? "text" : "disabled",
|
||||
cursor: tool.enabled ? "pointer" : "not-allowed",
|
||||
flexDirection: "row",
|
||||
flexShrink: 0,
|
||||
alignItems: "center"
|
||||
}}
|
||||
onClick={tool.onClick}
|
||||
>
|
||||
<tool.icon
|
||||
size={18}
|
||||
color={tool.enabled ? "text" : "disabled"}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex
|
||||
sx={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
position: "relative"
|
||||
}}
|
||||
ref={this._cont}
|
||||
onClick={(e) => this.canvasClick(e)}
|
||||
>
|
||||
{loading ? <Loading color="static" size={60} /> : null}
|
||||
<Image
|
||||
draggable="false"
|
||||
sx={{
|
||||
transform: this.createTransform(x, y, zoom, rotate),
|
||||
cursor: zoom > 1 ? "grab" : "unset",
|
||||
transition: moving ? "none" : "all 0.1s",
|
||||
maxWidth: "80vw",
|
||||
maxHeight: "80vh",
|
||||
minWidth: "100px",
|
||||
minHeight: "100px",
|
||||
backgroundSize: "50px",
|
||||
transformOrigin: "center center"
|
||||
}}
|
||||
onMouseDown={(e) => this.startMove(e)}
|
||||
onTouchStart={(e) => this.startMove(e)}
|
||||
onMouseMove={(e) => this.duringMove(e)}
|
||||
onTouchMove={(e) => this.duringMove(e)}
|
||||
onMouseUp={() => this.endMove()}
|
||||
onMouseLeave={() => this.endMove()}
|
||||
onTouchEnd={() => this.endMove()}
|
||||
onClick={(e) => this.stopSideEffect(e)}
|
||||
onDoubleClick={(e) => this.shockZoom(e)}
|
||||
onLoad={() => this.setState({ loading: false })}
|
||||
src={image}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,19 +39,20 @@ import { useKeyboardListNavigation } from "../../hooks/use-keyboard-list-navigat
|
||||
import { AnimatedFlex } from "../animated";
|
||||
import { domAnimation, LazyMotion } from "framer-motion";
|
||||
|
||||
const CustomScrollbarsVirtualList = forwardRef<HTMLDivElement, ScrollerProps>(
|
||||
function CustomScrollbarsVirtualList(props, ref) {
|
||||
return (
|
||||
<ScrollContainer
|
||||
{...props}
|
||||
forwardedRef={(sRef) => {
|
||||
if (typeof ref === "function") ref(sRef);
|
||||
else if (ref) ref.current = sRef;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
export const CustomScrollbarsVirtualList = forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollerProps
|
||||
>(function CustomScrollbarsVirtualList(props, ref) {
|
||||
return (
|
||||
<ScrollContainer
|
||||
{...props}
|
||||
forwardedRef={(sRef) => {
|
||||
if (typeof ref === "function") ref(sRef);
|
||||
else if (ref) ref.current = sRef;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type ListContainerProps = {
|
||||
type: keyof typeof ListProfiles;
|
||||
@@ -113,7 +114,7 @@ function ListContainer(props: ListContainerProps) {
|
||||
element.focus()
|
||||
);
|
||||
},
|
||||
skip: (index) => items[index].type === "header",
|
||||
skip: (index) => !items[index] || items[index].type === "header",
|
||||
open: (index) => {
|
||||
const item = items[index];
|
||||
if (!item || !listRef.current) return;
|
||||
|
||||
@@ -23,7 +23,6 @@ import Notebook from "../notebook";
|
||||
import Tag from "../tag";
|
||||
import Topic from "../topic";
|
||||
import TrashItem from "../trash-item";
|
||||
import Attachment from "../attachment";
|
||||
import { db } from "../../common/db";
|
||||
import { getTotalNotes } from "../../common";
|
||||
import Reminder from "../reminder";
|
||||
@@ -111,10 +110,6 @@ const TrashProfile: ItemWrapper = ({ index, item, type }) => (
|
||||
<TrashItem index={index} item={item} date={getDate(item, type)} />
|
||||
);
|
||||
|
||||
const AttachmentProfile: ItemWrapper = ({ index, item }) => (
|
||||
<Attachment index={index} item={item} isCompact={false} />
|
||||
);
|
||||
|
||||
export const ListProfiles = {
|
||||
home: NotesProfile,
|
||||
notebooks: NotebooksProfile,
|
||||
@@ -122,8 +117,7 @@ export const ListProfiles = {
|
||||
reminders: RemindersProfile,
|
||||
tags: TagsProfile,
|
||||
topics: TopicsProfile,
|
||||
trash: TrashProfile,
|
||||
attachments: AttachmentProfile
|
||||
trash: TrashProfile
|
||||
} as const;
|
||||
|
||||
function getTags(item: Item) {
|
||||
@@ -191,8 +185,6 @@ function getReminder(noteId: string) {
|
||||
}
|
||||
|
||||
function getDate(item: Item, groupType: keyof typeof ListProfiles) {
|
||||
if (groupType === "attachments") return item.dateCreated;
|
||||
|
||||
const sortBy = db.settings?.getGroupOptions(groupType).sortBy;
|
||||
switch (sortBy) {
|
||||
case "dateEdited":
|
||||
|
||||
@@ -70,11 +70,11 @@ function ListItem(props) {
|
||||
const isMenuTarget = target && target === listItemRef.current;
|
||||
|
||||
const isSelected = useSelectionStore((store) => {
|
||||
const inInSelection =
|
||||
const isInSelection =
|
||||
store.selectedItems.findIndex((item) => props.item.id === item.id) > -1;
|
||||
return isFocused
|
||||
? store.selectedItems.length > 1 && inInSelection
|
||||
: inInSelection;
|
||||
? store.selectedItems.length > 1 && isInSelection
|
||||
: isInSelection;
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user