Compare commits

...

1 Commits

Author SHA1 Message Date
Ammar Ahmed
e6a52e27e4 mobile: improve attachments manager 2024-09-18 16:19:21 +05:00
14 changed files with 654 additions and 605 deletions

View File

@@ -54,15 +54,10 @@ export async function downloadAllAttachments() {
* @param onProgress
* @returns
*/
export async function downloadAttachments(
attachments,
onProgress,
canceled,
groupId
) {
export async function downloadAttachments(attachments) {
await createCacheDir();
if (!attachments || !attachments.length) return;
const result = new Map();
const groupId = `download-all-${Date.now()}`;
let outputFolder;
if (Platform.OS === "android") {
@@ -86,92 +81,117 @@ export async function downloadAttachments(
await RNFetchBlob.fs.mkdir(zipSourceFolder);
const isCancelled = () => {
if (useAttachmentStore.getState().downloading[groupId]?.canceled) {
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
useAttachmentStore.getState().setDownloading({
groupId,
current: 0,
total: 0,
success: false,
message: "Download cancelled",
canceled: true
});
return true;
}
};
for (let i = 0; i < attachments.length; i++) {
if (isCancelled()) return;
let attachment = await db.attachments.attachment(attachments[i]);
const hash = attachment.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`
);
useAttachmentStore.getState().setDownloading({
groupId: groupId,
current: i + 1,
total: attachments.length,
filename: attachment.hash
});
// 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 (isCancelled()) 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.filename}`;
await RNFetchBlob.fs.mv(`${cacheDir}/${uri}`, filePath);
result.set(hash, {
filename: attachment.filename,
status: FileDownloadStatus.Success,
attachment: attachment
});
} catch (e) {
result.set(hash, {
filename: attachment.filename,
status: FileDownloadStatus.Fail,
reason: e
});
ToastManager.error(e, "Error downloading attachment");
DatabaseLogger.error(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`
);
useAttachmentStore.getState().setDownloading({
groupId: groupId,
current: 0,
total: 0,
success: true
});
if (isCancelled()) return;
let sub;
try {
useAttachmentStore.getState().setDownloading({
current: 0,
total: 1,
message: "Saving zip file... Please wait",
groupId
});
// If all goes well, zip the notesnook-attachments folder in cache.
sub = subscribe(({ progress }) => {
useAttachmentStore.getState().setDownloading({
groupId,
current: progress,
total: 1,
message: `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);
}
});
await zip(zipSourceFolder, zipOutputFile);
sub?.remove();
onProgress?.(1, `Done`);
releasePermissions(outputFolder);
} catch (e) {
releasePermissions(outputFolder);
sub?.remove();
ToastManager.error(e, "Error zipping attachments");
}
// Remove source & zip file from cache.
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
if (Platform.OS === "android") {
RNFetchBlob.fs.unlink(zipOutputFile).catch(console.log);
// 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);
}
}
return result;
useAttachmentStore.getState().setDownloading({
current: 0,
total: 0,
message: undefined,
success: true,
groupId
});
releasePermissions(outputFolder);
} catch (e) {
useAttachmentStore.getState().setDownloading({
current: 0,
total: 0,
message: undefined,
success: true,
groupId
});
releasePermissions(outputFolder);
sub?.remove();
ToastManager.error(e, "Error zipping attachments");
}
// Remove source & zip file from cache.
RNFetchBlob.fs.unlink(zipSourceFolder).catch(console.log);
if (Platform.OS === "android") {
RNFetchBlob.fs.unlink(zipOutputFile).catch(console.log);
}
}
export default async function downloadAttachment(

View File

@@ -30,6 +30,8 @@ import filesystem from "../../common/filesystem";
import downloadAttachment from "../../common/filesystem/download-attachment";
import { useAttachmentProgress } from "../../hooks/use-attachment-progress";
import picker from "../../screens/editor/tiptap/picker";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../screens/editor/tiptap/utils";
import {
ToastManager,
eSendEvent,
@@ -49,14 +51,12 @@ import { Dialog } from "../dialog";
import { presentDialog } from "../dialog/functions";
import { openNote } from "../list-items/note/wrapper";
import { DateMeta } from "../properties/date-meta";
import SheetProvider from "../sheet-provider";
import { Button } from "../ui/button";
import { Notice } from "../ui/notice";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../screens/editor/tiptap/utils";
import SheetProvider from "../sheet-provider";
const Actions = ({
attachment,
@@ -88,7 +88,7 @@ const Actions = ({
useAttachmentStore.getState().remove(attachment.hash);
}
downloadAttachment(attachment.hash, false);
eSendEvent(eCloseSheet, contextId);
fwdRef.current?.hide();
},
icon: "download"
},
@@ -149,9 +149,8 @@ const Actions = ({
},
{
name: "Rename",
onPress: () => {
onPress: async () => {
presentDialog({
context: contextId as any,
input: true,
title: "Rename file",
paragraph: "Enter a new name for the file",
@@ -219,6 +218,7 @@ const Actions = ({
style={{
maxHeight: "100%"
}}
keyboardShouldPersistTaps="never"
>
<Dialog context={contextId} />
<SheetProvider context={contextId} />

View File

@@ -22,7 +22,6 @@ import { Attachment, VirtualizedGrouping } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
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 { useDBItem } from "../../hooks/use-db-item";
@@ -44,7 +43,8 @@ export const AttachmentItem = ({
setAttachments,
pressable = true,
hideWhenNotDownloading,
context
context,
errorOnly
}: {
id: string | number;
attachments?: VirtualizedGrouping<Attachment>;
@@ -53,9 +53,9 @@ export const AttachmentItem = ({
pressable?: boolean;
hideWhenNotDownloading?: boolean;
context?: string;
errorOnly?: boolean;
}) => {
const [attachment] = useDBItem(id, "attachment", attachments);
const { colors } = useThemeColors();
const [currentProgress, setCurrentProgress] = useAttachmentProgress(
attachment,
@@ -67,8 +67,7 @@ export const AttachmentItem = ({
Actions.present(attachment, setAttachments, context);
};
return hideWhenNotDownloading &&
(!currentProgress || !currentProgress.value) ? null : (
return errorOnly && attachment && !attachment?.failed ? null : (
<TouchableOpacity
activeOpacity={0.9}
onPress={onPress}
@@ -78,8 +77,6 @@ export const AttachmentItem = ({
justifyContent: "space-between",
padding: 12,
paddingVertical: 6,
borderRadius: 5,
backgroundColor: colors.secondary.background,
minHeight: 45
}}
>
@@ -96,20 +93,26 @@ export const AttachmentItem = ({
style={{
justifyContent: "center",
alignItems: "center",
marginLeft: -5
marginLeft: -5,
borderWidth: 1,
borderColor: colors.secondary.border,
paddingHorizontal: 2,
minWidth: 20,
height: 30,
borderRadius: 5
}}
>
<Icon name="file" size={SIZE.xxxl} color={colors.primary.icon} />
<Paragraph
adjustsFontSizeToFit
size={6}
color={colors.static.white}
size={8}
color={colors.secondary.paragraph}
style={{
position: "absolute"
maxWidth: 20
}}
numberOfLines={1}
>
{getFileExtension(attachment.filename).toUpperCase()}
{getFileExtension(attachment.filename).toUpperCase() ||
attachment.mimeType.split("/")?.[1]?.toUpperCase()}
</Paragraph>
</View>
@@ -120,10 +123,9 @@ export const AttachmentItem = ({
}}
>
<Paragraph
size={SIZE.sm - 1}
size={SIZE.sm}
style={{
flexWrap: "wrap",
marginBottom: 2.5
flexWrap: "wrap"
}}
numberOfLines={1}
lineBreakMode="middle"
@@ -134,10 +136,7 @@ export const AttachmentItem = ({
{!hideWhenNotDownloading ? (
<Paragraph color={colors.secondary.paragraph} size={SIZE.xs}>
{formatBytes(attachment.size)}{" "}
{currentProgress?.type
? "(" + currentProgress.type + "ing - tap to cancel)"
: ""}
File size: {formatBytes(attachment.size)}
</Paragraph>
) : null}
</View>

View File

@@ -1,297 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useRef, useState } from "react";
import { Platform, View } from "react-native";
import { db } from "../../common/database";
import { downloadAttachments } from "../../common/filesystem/download-attachment";
import {
PresentSheetOptions,
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 { useThemeColors } from "@notesnook/theme";
import { FlatList } from "react-native-actions-sheet";
import { AttachmentItem } from "./attachment-item";
import { Attachment, VirtualizedGrouping } from "@notesnook/core";
const DownloadAttachments = ({
close,
attachments,
isNote,
update
}: {
attachments: VirtualizedGrouping<Attachment>;
close?: ((ctx?: string | undefined) => void) | undefined;
isNote?: boolean;
update?: (props: PresentSheetOptions) => void;
}) => {
const { colors } = useThemeColors();
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<string>();
const onDownload = async () => {
update?.({
disableClosing: true
} as PresentSheetOptions);
setDownloading(true);
canceled.current = false;
groupId.current = Date.now().toString();
const result = await downloadAttachments(
await attachments.ids(),
(progress: number, statusText: string) =>
setProgress({ value: progress, statusText }),
canceled,
groupId.current
);
if (canceled.current) return;
setResult(result || new Map());
setDownloading(false);
update?.({
disableClosing: false
} as PresentSheetOptions);
};
const cancel = async () => {
update?.({
disableClosing: false
} as PresentSheetOptions);
canceled.current = true;
if (!groupId.current) return;
console.log(groupId.current, "canceling groupId downloads");
await db.fs().cancel(groupId.current);
setDownloading(false);
setResult(new Map());
groupId.current = undefined;
};
const failedResults = () => {
const results = [];
for (const value of result.values()) {
if (value.status === 0) results.push(value.attachment);
}
return results;
};
function getResultText() {
const downloadedAttachmentsCount =
attachments?.placeholders?.length - failedResults().length;
if (downloadedAttachmentsCount === 0)
return "Failed to download all attachments";
return `Successfully downloaded ${downloadedAttachmentsCount}/${
attachments?.placeholders.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.placeholders?.length
: 0
}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
) : null}
<FlatList
style={{
maxHeight: 300,
width: "100%",
minHeight: 60,
backgroundColor: colors.secondary.background,
borderRadius: 5,
marginVertical: 12
}}
data={downloading ? attachments.placeholders : undefined}
ListEmptyComponent={
<View
style={{
width: "100%",
justifyContent: "center",
alignItems: "center",
height: 60
}}
>
<Paragraph color={colors.secondary.paragraph}>
No downloads in progress.
</Paragraph>
</View>
}
keyExtractor={(index) => "attachment_download" + index}
renderItem={({ index }) => {
return (
<AttachmentItem
id={index}
setAttachments={() => {}}
pressable={false}
hideWhenNotDownloading={true}
attachments={attachments}
/>
);
}}
/>
{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="secondary"
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: string,
attachments: VirtualizedGrouping<Attachment>,
isNote?: boolean
) => {
presentSheet({
context: context,
component: (ref, close, update) => (
<DownloadAttachments
close={close}
attachments={attachments}
isNote={isNote}
update={update}
/>
)
});
};
export default DownloadAttachments;

View File

@@ -26,14 +26,21 @@ import {
import { FilteredSelector } from "@notesnook/core/dist/database/sql-collection";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { ActivityIndicator, View } from "react-native";
import { FlashList } from "react-native-actions-sheet/dist/src/views/FlashList";
import { ScrollView } from "react-native-gesture-handler";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import create from "zustand";
import { db } from "../../common/database";
import filesystem from "../../common/filesystem";
import { presentSheet } from "../../services/event-manager";
import { useSettingStore } from "../../stores/use-setting-store";
import { downloadAttachments } from "../../common/filesystem/download-attachment";
import { AttachmentGroupProgress } from "../../screens/settings/offline-mode-progress";
import { presentSheet, ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { SIZE } from "../../utils/size";
import { Dialog } from "../dialog";
import { presentDialog } from "../dialog/functions";
import { Header } from "../header";
import SheetProvider from "../sheet-provider";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
@@ -42,22 +49,74 @@ import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { AttachmentItem } from "./attachment-item";
import DownloadAttachments from "./download-attachments";
const DEFAULT_SORTING: SortOptions = {
sortBy: "dateEdited",
sortDirection: "desc"
};
export const AttachmentDialog = ({ note }: { note?: Note }) => {
const useRechecker = create(
() =>
({
failed: 0,
passed: 0,
isWorking: false
} as {
failed: number;
passed: number;
isWorking: boolean;
shown: boolean;
})
);
const attachmentTypes = [
{
title: "All",
filterBy: "all"
},
{
title: "Images",
filterBy: "images"
},
{
title: "Docs",
filterBy: "documents"
},
{
title: "Video",
filterBy: "video"
},
{
title: "Audio",
filterBy: "audio"
},
{
title: "Orphaned",
filterBy: "orphaned"
},
{
title: "Errors",
filterBy: "errors"
}
];
export const AttachmentDialog = ({
note,
isSheet
}: {
note?: Note;
isSheet: boolean;
}) => {
const { colors } = useThemeColors();
const { height } = useSettingStore((state) => state.dimensions);
const [attachments, setAttachments] =
useState<VirtualizedGrouping<Attachment>>();
const attachmentSearchValue = useRef<string>();
const [loading, setLoading] = useState(true);
const searchTimer = useRef<NodeJS.Timeout>();
const [loading, setLoading] = useState(false);
const [currentFilter, setCurrentFilter] = useState("all");
const rechecker = useRechecker();
const currentFilterRef = useRef(currentFilter);
currentFilterRef.current = currentFilter;
const refresh = React.useCallback(() => {
if (note) {
@@ -68,6 +127,7 @@ export const AttachmentDialog = ({ note }: { note?: Note }) => {
sortBy: "dateModified"
})
.then((attachments) => {
setLoading(false);
setAttachments(attachments);
});
} else {
@@ -76,7 +136,10 @@ export const AttachmentDialog = ({ note }: { note?: Note }) => {
...DEFAULT_SORTING,
sortBy: "dateModified"
})
.then((attachments) => setAttachments(attachments));
.then((attachments) => {
setAttachments(attachments);
setLoading(false);
});
}
}, [note]);
@@ -108,53 +171,55 @@ export const AttachmentDialog = ({ note }: { note?: Note }) => {
setAttachments={async () => {
setAttachments(await filterAttachments(currentFilter));
}}
errorOnly={currentFilter === "errors"}
attachments={attachments}
id={index}
context="attachments-list"
context="global"
/>
);
const onCheck = async () => {
if (!attachments) return;
setLoading(true);
if (!attachments || useRechecker.getState().isWorking) return;
useRechecker.setState({
isWorking: true,
failed: 0,
passed: 0,
shown: true
});
for (let i = 0; i < attachments.placeholders.length; i++) {
if (!useRechecker.getState().isWorking) {
ToastManager.show({
message: "Attachment recheck cancelled",
type: "info",
context: isSheet ? "local" : "global"
});
return;
}
const attachment = (await attachments.item(i))?.item;
if (currentFilter == "errors" && !attachment?.failed) continue;
if (!attachment) continue;
const result = await filesystem.checkAttachment(attachment.hash);
if (!result) return;
if (result.failed) {
useRechecker.setState({
failed: useRechecker.getState().failed + 1
});
await db.attachments.markAsFailed(attachment.hash, result.failed);
} else {
useRechecker.setState({
passed: useRechecker.getState().passed + 1
});
await db.attachments.markAsFailed(attachment.id);
}
}
refresh();
setLoading(false);
};
setAttachments(await filterAttachments(currentFilter));
const attachmentTypes = [
{
title: "All",
filterBy: "all"
},
{
title: "Images",
filterBy: "images"
},
{
title: "Documents",
filterBy: "documents"
},
{
title: "Video",
filterBy: "video"
},
{
title: "Audio",
filterBy: "audio"
}
];
useRechecker.setState({
isWorking: false
});
};
const filterAttachments = async (type: string) => {
let items: FilteredSelector<Attachment> = db.attachments.all;
@@ -184,6 +249,14 @@ export const AttachmentDialog = ({ note }: { note?: Note }) => {
items = note
? db.attachments.ofNote(note.id, "documents")
: db.attachments.documents;
break;
case "orphaned":
items = db.attachments.orphaned;
break;
case "errors":
items = items = note
? db.attachments.ofNote(note.id, "all")
: db.attachments.all;
}
return await items.sorted({
@@ -193,39 +266,59 @@ export const AttachmentDialog = ({ note }: { note?: Note }) => {
};
return (
<View
style={{
width: "100%",
alignSelf: "center",
paddingHorizontal: 12,
height: height * 0.85
}}
>
<SheetProvider context="attachments-list" />
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Heading>Attachments</Heading>
<>
{isSheet ? (
<>
<SheetProvider context="attachments-list" />
<Dialog context="local" />
</>
) : null}
{!isSheet ? (
<Header
title="Manage attachments"
renderedInRoute="SettingsGroup"
canGoBack
headerRightButtons={[
{
onPress() {
onCheck();
},
title: "Recheck all"
},
{
onPress() {
if (!attachments) return;
presentDialog({
title: `Download ${attachments.placeholders.length} attachments`,
paragraph:
"Are you sure you want to download all attachments?",
positiveText: "Download",
positivePress: async () => {
downloadAttachments(await attachments.ids());
},
negativeText: "Cancel"
});
},
title: "Download all"
}
]}
/>
) : (
<View
style={{
flexDirection: "row"
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 12
}}
>
{loading ? (
<ActivityIndicator
style={{
height: 40,
width: 40,
marginRight: 10
}}
size={SIZE.lg}
/>
) : (
<Heading>Attachments</Heading>
<View
style={{
flexDirection: "row"
}}
>
<IconButton
name="check-all"
style={{
@@ -237,126 +330,224 @@ export const AttachmentDialog = ({ note }: { note?: Note }) => {
size={SIZE.lg}
onPress={onCheck}
/>
)}
<IconButton
name="download"
style={{
height: 40,
width: 40
}}
color={colors.primary.paragraph}
onPress={() => {
if (!attachments) return;
DownloadAttachments.present(
"attachments-list",
attachments,
!!note
);
}}
size={SIZE.lg}
/>
</View>
</View>
<Seperator />
<Input
placeholder="Filter attachments by filename, type or hash"
onChangeText={onChangeText}
onSubmit={() => {
onChangeText(attachmentSearchValue.current as string);
}}
/>
<View>
<ScrollView
style={{
width: "100%",
height: 50,
flexDirection: "row",
backgroundColor: colors.primary.background
}}
contentContainerStyle={{
minWidth: "100%",
height: 50
}}
horizontal
>
{attachmentTypes.map((item) => (
<Button
type={
currentFilter === item.filterBy ? "secondaryAccented" : "plain"
}
key={item.title}
title={item.title}
<IconButton
name="download"
style={{
borderRadius: 0,
borderBottomWidth: 1,
flexGrow: 1,
borderBottomColor:
currentFilter !== item.filterBy
? "transparent"
: colors.primary.accent
height: 40,
width: 40
}}
onPress={async () => {
setCurrentFilter(item.filterBy);
setAttachments(await filterAttachments(item.filterBy));
color={colors.primary.paragraph}
onPress={() => {
if (!attachments) return;
presentDialog({
title: `Download ${attachments.placeholders.length} attachments`,
paragraph:
"Are you sure you want to download all attachments?",
context: "local",
positiveText: "Download",
positivePress: async () => {
downloadAttachments(await attachments.ids());
},
negativeText: "Cancel"
});
}}
size={SIZE.lg}
/>
))}
</ScrollView>
</View>
<FlashList
keyboardDismissMode="none"
keyboardShouldPersistTaps="always"
ListEmptyComponent={
<View
style={{
height: 150,
justifyContent: "center",
alignItems: "center"
}}
>
<Icon name="attachment" size={60} color={colors.secondary.icon} />
<Paragraph>
{note ? "No attachments on this note" : "No attachments"}
</Paragraph>
</View>
}
ListFooterComponent={
<View
style={{
height: 350
}}
/>
}
estimatedItemSize={50}
data={attachments?.placeholders}
renderItem={renderItem}
/>
</View>
)}
<Paragraph
color={colors.secondary.paragraph}
size={SIZE.xs}
<View
style={{
textAlign: "center",
marginTop: 10
width: "100%",
alignSelf: "center",
paddingHorizontal: 12,
height: "100%"
}}
>
<Icon
name="shield-key-outline"
size={SIZE.xs}
color={colors.primary.icon}
<Seperator />
<Input
placeholder="Filter attachments by filename, type or hash"
onChangeText={onChangeText}
onSubmit={() => {
onChangeText(attachmentSearchValue.current as string);
}}
/>
{" "}All attachments are end-to-end encrypted.
</Paragraph>
{rechecker.shown ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
width: "100%",
borderRadius: 10,
padding: 10,
borderWidth: 1,
borderColor: colors.primary.border,
gap: 12,
justifyContent: "space-between"
}}
>
<View
style={{
flexDirection: "row",
gap: 12
}}
>
{rechecker.isWorking ? (
<ActivityIndicator color={colors.primary.accent} />
) : (
<Icon name="check" size={30} color={colors.primary.accent} />
)}
<View>
<Paragraph>
{rechecker.isWorking
? note
? `Checking ${currentFilter.toLowerCase()} note attachments`
: `Checking ${currentFilter.toLowerCase()} attachments`
: "Attachments recheck complete"}
</Paragraph>
<Paragraph>
{`${rechecker.isWorking ? "Please wait... " : ""}Passed: ${
rechecker.passed
}, Failed: ${rechecker.failed}`}
</Paragraph>
</View>
</View>
<IconButton
type="errorShade"
name="close"
size={SIZE.lg}
color={colors.error.icon}
onPress={() => {
useRechecker.setState({
shown: false,
isWorking: false
});
}}
/>
</View>
) : null}
<View>
<ScrollView
style={{
backgroundColor: colors.primary.background,
flexWrap: "wrap",
flexDirection: "row",
paddingVertical: 12
}}
contentContainerStyle={{
alignItems: "center",
paddingRight: 50
}}
horizontal
>
{attachmentTypes.map((item) =>
item.filterBy === "orphaned" && note ? null : (
<Button
type={
currentFilter === item.filterBy
? "secondaryAccented"
: "plain"
}
key={item.title}
title={item.title}
fontSize={SIZE.sm}
style={{
borderRadius: 100,
paddingHorizontal: 12,
height: 40,
minWidth: 80
}}
onPress={async () => {
const filterBy = item.filterBy;
setCurrentFilter(filterBy);
setLoading(true);
filterAttachments(filterBy)
.then((results) => {
if (filterBy !== currentFilterRef.current) return;
setAttachments(results);
setLoading(false);
})
.catch(console.log);
}}
/>
)
)}
</ScrollView>
</View>
<FlashList
keyboardDismissMode="none"
keyboardShouldPersistTaps="always"
ListEmptyComponent={
<View
style={{
height: "100%",
justifyContent: "center",
alignItems: "center"
}}
>
{loading ? (
<ActivityIndicator size={40} color={colors.primary.accent} />
) : (
<>
<Icon
name="attachment"
size={60}
color={colors.secondary.icon}
/>
<Paragraph>
{note ? "No attachments on this note" : "No attachments"}
</Paragraph>
</>
)}
</View>
}
ListHeaderComponent={<AllProgress />}
ListFooterComponent={
<View
style={{
height: 350
}}
/>
}
estimatedItemSize={50}
data={loading ? [] : attachments?.placeholders}
renderItem={renderItem}
/>
</View>
</>
);
};
const AllProgress = () => {
const progress = useAttachmentStore();
return (
<View
style={{
gap: 10,
width: "100%"
}}
>
{Object.keys(progress.downloading || {}).map((groupId) => (
<AttachmentGroupProgress key={groupId} groupId={groupId} />
))}
{Object.keys(progress.uploading || {}).map((groupId) => (
<AttachmentGroupProgress key={groupId} groupId={groupId} />
))}
</View>
);
};
AttachmentDialog.present = (note?: Note) => {
presentSheet({
component: () => <AttachmentDialog note={note} />,
component: () => <AttachmentDialog note={note} isSheet={true} />,
keyboardHandlerDisabled: true
});
};

View File

@@ -67,8 +67,6 @@ export const Header = ({
hasSearch?: boolean;
onSearch?: () => void;
}) => {
const navigation = useNavigation();
const { colors } = useThemeColors();
const insets = useGlobalSafeAreaInsets();
const [borderHidden, setBorderHidden] = useState(true);

View File

@@ -96,7 +96,8 @@ export const RightMenus = ({
animationDuration={200}
style={{
borderRadius: 5,
backgroundColor: contextMenuColors.primary.background
backgroundColor: contextMenuColors.primary.background,
marginTop: -40
}}
onRequestClose={() => {
menuRef.current?.hide();

View File

@@ -18,26 +18,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactElement } from "react";
import { AttachmentDialog } from "../../components/attachments";
import { AccentColorPicker } from "./appearance";
import DebugLogs from "./debug";
import { ConfigureToolbar } from "./editor/configure-toolbar";
import { Licenses } from "./licenses";
import SoundPicker from "./sound-picker";
import { Subscription } from "./subscription";
import { TitleFormat } from "./title-format";
import { AttachmentGroupProgress } from "./offline-mode-progress";
import {
HomePicker,
ApplockTimerPicker,
BackupReminderPicker,
BackupWithAttachmentsReminderPicker,
DateFormatPicker,
FontPicker,
HomePicker,
TimeFormatPicker,
TrashIntervalPicker,
BackupReminderPicker,
ApplockTimerPicker,
BackupWithAttachmentsReminderPicker
TrashIntervalPicker
} from "./picker/pickers";
import ThemeSelector from "./theme-selector";
import { RestoreBackup } from "./restore-backup";
import { ServersConfiguration } from "./server-config";
import SoundPicker from "./sound-picker";
import { Subscription } from "./subscription";
import ThemeSelector from "./theme-selector";
import { TitleFormat } from "./title-format";
export const components: { [name: string]: ReactElement } = {
colorpicker: <AccentColorPicker />,
@@ -57,5 +59,7 @@ export const components: { [name: string]: ReactElement } = {
"applock-timer": <ApplockTimerPicker />,
autobackupsattachments: <BackupWithAttachmentsReminderPicker />,
backuprestore: <RestoreBackup />,
"server-config": <ServersConfiguration />
"server-config": <ServersConfiguration />,
"attachments-manager": <AttachmentDialog note={undefined} isSheet={false} />,
"offline-mode-progress": <AttachmentGroupProgress groupId="offline-mode" />
};

View File

@@ -23,13 +23,13 @@ import { View } from "react-native";
import { KeyboardAwareFlatList } from "react-native-keyboard-aware-scroll-view";
import Animated, { FadeInDown } from "react-native-reanimated";
import DelayLayout from "../../components/delay-layout";
import { Header } from "../../components/header";
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
import useNavigationStore from "../../stores/use-navigation-store";
import { tabBarRef } from "../../utils/global-refs";
import { components } from "./components";
import { SectionItem } from "./section-item";
import { RouteParams, SettingSection } from "./types";
import { Header } from "../../components/header";
const keyExtractor = (item: SettingSection) => item.id;
const AnimatedKeyboardAvoidingFlatList = Animated.createAnimatedComponent(
@@ -58,18 +58,21 @@ const Group = ({
return (
<>
<Header
renderedInRoute="Settings"
title={route.params.name as string}
canGoBack={true}
id="Settings"
/>
{route.params.hideHeader ? null : (
<Header
renderedInRoute="Settings"
title={route.params.name as string}
canGoBack={true}
id="Settings"
/>
)}
<DelayLayout type="settings" delay={1}>
<View
style={{
flex: 1
}}
>
{route.params.component ? components[route.params.component] : null}
{route.params.sections ? (
<AnimatedKeyboardAvoidingFlatList
entering={FadeInDown}
@@ -80,7 +83,6 @@ const Group = ({
enableAutomaticScroll
/>
) : null}
{route.params.component ? components[route.params.component] : null}
</View>
</DelayLayout>
</>

View File

@@ -0,0 +1,124 @@
/*
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 { formatBytes } from "@notesnook/common";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { IconButton } from "../../components/ui/icon-button";
import { ProgressBarComponent } from "../../components/ui/svg/lazy";
import Paragraph from "../../components/ui/typography/paragraph";
import { useAttachmentProgress } from "../../hooks/use-attachment-progress";
import { useDBItem } from "../../hooks/use-db-item";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { SIZE } from "../../utils/size";
export const AttachmentGroupProgress = (props: { groupId?: string }) => {
const { colors } = useThemeColors();
const progress = useAttachmentStore((state) =>
!props.groupId ? undefined : state.downloading?.[props.groupId]
);
const [file] = useDBItem(progress?.filename, "attachment");
const [fileProgress] = useAttachmentProgress(file, false);
return !progress ||
progress.success ||
progress.canceled ||
progress.current === progress.total ? null : (
<View
style={{
borderWidth: 1,
borderColor: colors.primary.border,
borderRadius: 10,
padding: 12,
flexDirection: "row",
gap: 10
}}
>
<Icon name="download" size={SIZE.xxxl} />
<View
style={{
gap: 5,
flex: 1
}}
>
<Paragraph>
{progress.message || "Downloading files"} ({progress?.current}/
{progress?.total})
</Paragraph>
{progress && progress.current && progress.total ? (
<View
style={{
width: "100%",
marginTop: 10
}}
>
<ProgressBarComponent
height={5}
width={null}
animated={true}
useNativeDriver
progress={progress.current / progress.total}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
) : null}
<Paragraph
style={{
fontSize: 10,
color: colors.secondary.paragraph
}}
numberOfLines={1}
>
Downloading {file?.filename} {formatBytes(file?.size || 0)}{" "}
{fileProgress?.percent ? `(${fileProgress.percent})` : ""}
</Paragraph>
<Paragraph size={10} color={colors.secondary.paragraph}>
Group: {props.groupId}
</Paragraph>
</View>
{props.groupId === "offline-mode" ? null : (
<IconButton
name="close"
onPress={() => {
if (props.groupId) {
useAttachmentStore.getState().setDownloading({
groupId: props.groupId,
canceled: true,
current: 0,
total: 0,
message: undefined
});
setTimeout(() => {
if (props.groupId) {
db.fs().cancel(props.groupId);
}
});
}
}}
/>
)}
</View>
);
};

View File

@@ -29,7 +29,6 @@ import ScreenGuardModule from "react-native-screenguard";
import { DatabaseLogger, db } from "../../common/database";
import { MMKV } from "../../common/database/mmkv";
import filesystem from "../../common/filesystem";
import { AttachmentDialog } from "../../components/attachments";
import { ChangePassword } from "../../components/auth/change-password";
import { presentDialog } from "../../components/dialog/functions";
import { AppLockPassword } from "../../components/dialogs/applock-password";
@@ -209,10 +208,10 @@ export const settingsGroups: SettingSection[] = [
id: "manage-attachments",
name: "Manage attachments",
icon: "attachment",
modifer: () => {
AttachmentDialog.present();
},
description: "Manage all attachments in one place."
type: "screen",
component: "attachments-manager",
description: "Manage all attachments in one place.",
hideHeader: true
},
{
id: "change-password",
@@ -545,6 +544,7 @@ export const settingsGroups: SettingSection[] = [
description: "Configure syncing for this device",
type: "screen",
icon: "autorenew",
component: "offline-mode-progress",
sections: [
{
id: "offline-mode",

View File

@@ -46,6 +46,7 @@ export type SettingSection = {
minInputValue?: number;
maxInputValue?: number;
onVerify?: () => Promise<boolean>;
hideHeader?: boolean;
};
export type SettingsGroup = {

View File

@@ -139,6 +139,7 @@ export type ToastOptions = {
duration?: number;
func?: () => void;
actionText?: string;
icon?: string;
};
export const ToastManager = {

View File

@@ -18,8 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import create from "zustand";
import { editorController } from "../screens/editor/tiptap/utils";
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
import { editorController } from "../screens/editor/tiptap/utils";
export type AttachmentGroupProgress = {
total: number;
@@ -29,6 +29,7 @@ export type AttachmentGroupProgress = {
canceled?: boolean;
success?: boolean;
error?: any;
message?: string;
};
interface AttachmentStore {
@@ -53,13 +54,13 @@ interface AttachmentStore {
type: "upload" | "download"
) => void;
downloading?: {
[groupId: string]: AttachmentGroupProgress | undefined;
[groupId: string]: Partial<AttachmentGroupProgress> | undefined;
};
setDownloading: (data: AttachmentGroupProgress) => void;
setDownloading: (data: Partial<AttachmentGroupProgress>) => void;
uploading?: {
[groupId: string]: AttachmentGroupProgress | undefined;
[groupId: string]: Partial<AttachmentGroupProgress> | undefined;
};
setUploading: (data: AttachmentGroupProgress) => void;
setUploading: (data: Partial<AttachmentGroupProgress>) => void;
}
export const useAttachmentStore = create<AttachmentStore>((set, get) => ({
@@ -100,18 +101,22 @@ export const useAttachmentStore = create<AttachmentStore>((set, get) => ({
downloading: {},
setDownloading: (data) =>
set({
downloading: {
...get().downloading,
[data.groupId]: data?.canceled ? undefined : data
}
}),
!data.groupId
? null
: set({
downloading: {
...get().downloading,
[data.groupId]: data
}
}),
uploading: {},
setUploading: (data) =>
set({
uploading: {
...get().uploading,
[data.groupId]: data?.canceled ? undefined : data
}
})
!data.groupId
? null
: set({
uploading: {
...get().uploading,
[data.groupId]: data
}
})
}));