Add variable storage locations & batch adding

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-28 21:18:38 +03:00
parent cecede4e28
commit 2a5165abf6
23 changed files with 412 additions and 180 deletions

View File

@@ -79,7 +79,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionName "0.0.3"
}
signingConfigs {
debug {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@karaolidis/terminally-online",
"version": "0.0.2",
"version": "0.0.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@karaolidis/terminally-online",
"version": "0.0.2",
"version": "0.0.3",
"hasInstallScript": true,
"dependencies": {
"@bankify/redux-persist-realm": "^0.1.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@karaolidis/terminally-online",
"version": "0.0.2",
"version": "0.0.3",
"private": true,
"scripts": {
"postinstall": "patch-package",

View File

@@ -54,13 +54,13 @@ const App = () => {
}, []);
return (
<PaperProvider theme={theme}>
<ReduxProvider store={store}>
<PersistGate
loading={<LoadingView />}
persistor={persistor}
onBeforeLift={onBeforeLift}>
<RealmProvider schema={[Meme, Tag]}>
<ReduxProvider store={store}>
<RealmProvider schema={[Meme, Tag]}>
<PaperProvider theme={theme}>
<PersistGate
loading={<LoadingView />}
persistor={persistor}
onBeforeLift={onBeforeLift}>
<GestureHandlerRootView style={appStyles.gestureHandler}>
<SafeAreaProvider>
<StatusBar
@@ -74,10 +74,10 @@ const App = () => {
)}
</SafeAreaProvider>
</GestureHandlerRootView>
</RealmProvider>
</PersistGate>
</ReduxProvider>
</PaperProvider>
</PersistGate>
</PaperProvider>
</RealmProvider>
</ReduxProvider>
);
};

View File

@@ -3,10 +3,10 @@ import { Keyboard, StyleSheet } from 'react-native';
import { FAB } from 'react-native-paper';
import { ParamListBase, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { pickSingle } from 'react-native-document-picker';
import { pick } from 'react-native-document-picker';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { ROUTE } from '../types';
import { allowedMimeTypes, noOp } from '../utilities';
import { useDeviceOrientation } from '@react-native-community/hooks';
const floatingActionButtonStyles = StyleSheet.create({
fab: {
@@ -65,9 +65,12 @@ const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => {
onStateChange={({ open }) => setState(open)}
onPress={async () => {
if (!state) return;
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
if (!file) return;
navigate(ROUTE.ADD_MEME, { file });
const files = await pick({
type: allowedMimeTypes,
allowMultiSelection: true,
}).catch(noOp);
if (!files) return;
navigate(ROUTE.ADD_MEME, { files });
}}
style={
orientation === 'portrait'

View File

@@ -10,3 +10,4 @@ export { default as FloatingActionButton } from './floatingActionButton';
export { default as HideableBottomNavigationBar } from './hideableBottomNavigationBar';
export { default as HideableHeader } from './hideableHeader';
export { default as LoadingView } from './loadingView';
export { default as storageLocationChangeDialog } from './storageLocationChangeDialog';

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper';
import { Image } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks/lib/useImageDimensions';
@@ -13,6 +13,10 @@ const memeEditorStyles = {
marginBottom: 15,
borderRadius: 5,
},
uri: {
marginBottom: 15,
marginHorizontal: 5,
},
memeTagSelector: {
marginBottom: 10,
},
@@ -23,27 +27,30 @@ const memeEditorStyles = {
const MemeEditor = ({
memeUri,
memeUriError,
setMemeUriError,
memeFilename,
memeError,
setMemeError,
memeTitle,
setMemeTitle,
memeTags,
setMemeTags,
}: {
memeUri: string;
memeUriError: Error | undefined;
setMemeUriError: (error: Error | undefined) => void;
memeFilename?: string;
memeError: Error | undefined;
setMemeError: (error: Error | undefined) => void;
memeTitle: StringValidationResult;
setMemeTitle: (name: StringValidationResult) => void;
memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void;
}) => {
const { width } = useSafeAreaFrame();
const { colors } = useTheme();
const { dimensions, loading, error } = useImageDimensions({ uri: memeUri });
useEffect(() => setMemeUriError(error), [error, setMemeUriError]);
useEffect(() => setMemeError(error), [error, setMemeError]);
if (!memeUriError && (loading || !dimensions)) return <LoadingView />;
if (!memeError && (loading || !dimensions)) return <LoadingView />;
return (
<>
@@ -58,7 +65,7 @@ const MemeEditor = ({
<HelperText type="error" visible={!memeTitle.valid}>
{memeTitle.error}
</HelperText>
{memeUriError || !dimensions ? (
{memeError || !dimensions ? (
<MemeFail
style={[
{
@@ -88,6 +95,12 @@ const MemeEditor = ({
resizeMode="contain"
/>
)}
<Text
variant="bodySmall"
style={[memeEditorStyles.uri, { color: colors.onSurfaceDisabled }]}
numberOfLines={1}>
{memeFilename}
</Text>
<MemeTagSelector
memeTags={memeTags}
setMemeTags={setMemeTags}

View File

@@ -3,9 +3,12 @@ import { ImageZoom } from '@likashefqet/react-native-image-zoom';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import { AndroidScoped } from 'react-native-file-access';
import LoadingView from '../loadingView';
import { Meme } from '../../database';
import MemeFail from './memeFail';
import { useSelector } from 'react-redux';
import { RootState } from '../../state';
const memeViewItemStyles = StyleSheet.create({
view: {
@@ -16,8 +19,14 @@ const memeViewItemStyles = StyleSheet.create({
const MemeViewItem = ({ meme }: { meme: Meme }) => {
const { height, width } = useSafeAreaFrame();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const { dimensions, loading, error } = useImageDimensions({ uri });
if (!error && (loading || !dimensions)) {
return (
@@ -39,7 +48,7 @@ const MemeViewItem = ({ meme }: { meme: Meme }) => {
/>
) : (
<ImageZoom
source={{ uri: meme.uri }}
source={{ uri }}
style={
dimensions.aspectRatio > width / (height - 128)
? {

View File

@@ -3,6 +3,7 @@ import { Image, TouchableHighlight } from 'react-native';
import { useSelector } from 'react-redux';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import { AndroidScoped } from 'react-native-file-access';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { MemeFail } from '..';
@@ -21,8 +22,14 @@ const MemesGridItem = ({
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const { dimensions, loading, error } = useImageDimensions({ uri });
if (!error && (loading || !dimensions)) return <></>;
@@ -38,7 +45,7 @@ const MemesGridItem = ({
/>
) : (
<Image
source={{ uri: meme.uri }}
source={{ uri }}
style={[
{
width: (width * 0.92 - 5) / gridColumns,

View File

@@ -3,8 +3,11 @@ import { Image, StyleSheet, View } from 'react-native';
import { Text, TouchableRipple } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import { AndroidScoped } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import { Meme } from '../../../database';
import { MemeFail } from '..';
import { RootState } from '../../../state';
const memesListItemStyles = StyleSheet.create({
view: {
@@ -40,8 +43,14 @@ const MemesListItem = ({
focusMeme: (index: number) => void;
}) => {
const { width } = useSafeAreaFrame();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const { dimensions, loading, error } = useImageDimensions({ uri });
if (!error && (loading || !dimensions)) return <></>;
@@ -53,7 +62,7 @@ const MemesListItem = ({
{error ? (
<MemeFail style={memesListItemStyles.image} />
) : (
<Image source={{ uri: meme.uri }} style={memesListItemStyles.image} />
<Image source={{ uri }} style={memesListItemStyles.image} />
)}
<View
style={[

View File

@@ -2,9 +2,10 @@ import React from 'react';
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
import { useSelector } from 'react-redux';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { AndroidScoped } from 'react-native-file-access';
import { useImageDimensions } from '@react-native-community/hooks';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { useImageDimensions } from '@react-native-community/hooks';
import { MemeFail } from '..';
import { getFontAwesome5IconSize } from '../../../utilities';
@@ -31,8 +32,14 @@ const MemesMasonryItem = ({
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const { dimensions, loading, error } = useImageDimensions({ uri });
if (!error && (loading || !dimensions)) return <></>;
@@ -53,7 +60,7 @@ const MemesMasonryItem = ({
/>
) : (
<Image
source={{ uri: meme.uri }}
source={{ uri }}
style={[
memeMasonryItemStyles.image,
{

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
import { Dialog, ProgressBar, Text } from 'react-native-paper';
import { openDocumentTree } from 'react-native-scoped-storage';
import { useDispatch, useSelector } from 'react-redux';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { RootState, setStorageUri } from '../state';
import { clearPermissions, isPermissionForPath, noOp } from '../utilities';
const storageLocationChangeDialogStyles = StyleSheet.create({
progressBar: {
marginVertical: 15,
},
});
const StorageLocationChangeDialog = ({
visible,
setVisible,
setSnackbarVisible,
setSnackbarMessage,
}: {
visible: boolean;
setVisible: (visible: boolean) => void;
setSnackbarVisible: (visible: boolean) => void;
setSnackbarMessage: (message: string) => void;
}) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const dispatch = useDispatch();
const [progress, setProgress] = useState(0);
useEffect(() => {
const selectNewStorageUri = async () => {
const uri = await openDocumentTree(true).catch(noOp);
if (!uri) {
setVisible(false);
return;
}
const newStorageUri = uri.uri;
if (isPermissionForPath(storageUri, newStorageUri)) {
setSnackbarMessage('Folder already selected.');
setSnackbarVisible(true);
setVisible(false);
return;
}
const files = await FileSystem.ls(storageUri);
let filesCopied = 0;
await Promise.all(
files.map(async file => {
const oldUri = AndroidScoped.appendPath(storageUri, file);
const newUri = AndroidScoped.appendPath(newStorageUri, file);
// You may be wondering, why cp and unlink instead of mv?
// That's because Android is a fuck and does not allow moving across different scoped storage paths.
await FileSystem.cp(oldUri, newUri).catch(noOp);
await FileSystem.unlink(oldUri).catch(noOp);
filesCopied++;
setProgress(filesCopied / files.length);
}),
);
await dispatch(setStorageUri(newStorageUri));
await clearPermissions([newStorageUri]);
setVisible(false);
setProgress(0);
};
if (visible) void selectNewStorageUri();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
return (
<Dialog
visible={visible}
onDismiss={() => setVisible(false)}
dismissable={false}
dismissableBackButton={false}>
<Dialog.Title>Change Storage Location</Dialog.Title>
<Dialog.Content>
<Text>Copying files. Do not close the app.</Text>
<ProgressBar
animatedValue={progress}
style={storageLocationChangeDialogStyles.progressBar}
/>
</Dialog.Content>
</Dialog>
);
};
export default StorageLocationChangeDialog;

View File

@@ -21,7 +21,7 @@ const memeTypePlural = {
class Meme extends Object<Meme> {
id!: BSON.UUID;
type!: MEME_TYPE;
uri!: string;
filename!: string;
mimeType!: string;
size!: number;
title!: string;
@@ -39,7 +39,7 @@ class Meme extends Object<Meme> {
properties: {
id: { type: 'uuid', default: () => new BSON.UUID() },
type: { type: 'string', indexed: true },
uri: 'string',
filename: 'string',
mimeType: 'string',
size: 'int',
title: 'string',

View File

@@ -9,10 +9,7 @@ import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import { extension } from 'react-native-mime-types';
import { useDeviceOrientation } from '@react-native-community/hooks';
import {
DocumentPickerResponse,
pickSingle,
} from 'react-native-document-picker';
import { DocumentPickerResponse, pick } from 'react-native-document-picker';
import { ROUTE, RootStackParamList } from '../../types';
import { Meme, Tag } from '../../database';
import { RootState } from '../../state';
@@ -36,19 +33,24 @@ const AddMeme = ({
(state: RootState) => state.settings.storageUri,
)!;
const file = useRef(route.params.file);
const [index, setIndex] = useState(0);
const files = useRef(route.params.files);
const file = useRef(files.current[index]);
const isLastFile = index === files.current.length - 1;
const [memeUri, setMemeUri] = useState(file.current.uri);
const [memeUriError, setMemeUriError] = useState<Error>();
const [memeFilename, setMemeFilename] = useState(
file.current.name ?? undefined,
);
const [memeError, setMemeError] = useState<Error>();
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false);
const handleSave = useCallback(async () => {
const saveMeme = useCallback(async () => {
const uuid = new BSON.UUID();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mimeType = file.current.type!;
@@ -58,10 +60,10 @@ const AddMeme = ({
const fileExtension = extension(mimeType) as string;
if (!fileExtension) goBack();
const uri = AndroidScoped.appendPath(
storageUri,
`${uuid.toHexString()}-${Math.round(Date.now() / 1000)}.${fileExtension}`,
);
const filename = `${uuid.toHexString()}-${Math.round(
Date.now() / 1000,
)}.${fileExtension}`;
const uri = AndroidScoped.appendPath(storageUri, filename);
await FileSystem.cp(file.current.uri, uri);
const { size } = await FileSystem.stat(uri);
@@ -70,7 +72,7 @@ const AddMeme = ({
const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, {
id: uuid,
type: memeType,
uri,
filename,
mimeType,
size,
title: memeTitle.parsed,
@@ -87,6 +89,47 @@ const AddMeme = ({
});
}, [goBack, memeIsFavorite, memeTags, memeTitle.parsed, realm, storageUri]);
const handleSave = useCallback(async () => {
setIsSaving(true);
await saveMeme();
setIsSaving(false);
goBack();
}, [goBack, saveMeme]);
const handleSaveAndNext = useCallback(async () => {
setIsSaving(true);
await saveMeme();
setIsSaving(false);
setIndex(index + 1);
file.current = files.current[index + 1];
setMemeUri(file.current.uri);
setMemeFilename(file.current.name ?? undefined);
setMemeTitle(validateMemeTitle('New Meme'));
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
}, [index, saveMeme]);
const handleSaveAndAddMore = useCallback(async () => {
setIsSavingAndAddingMore(true);
await saveMeme();
setIsSavingAndAddingMore(false);
setIndex(0);
files.current = (await pick({
type: allowedMimeTypes,
allowMultiSelection: true,
}).catch(goBack)) as DocumentPickerResponse[];
file.current = files.current[0];
setMemeUri(file.current.uri);
setMemeFilename(file.current.name ?? undefined);
setMemeTitle(validateMemeTitle('New Meme'));
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
}, [goBack, saveMeme]);
return (
<>
<Appbar.Header>
@@ -98,7 +141,7 @@ const AddMeme = ({
/>
</Appbar.Header>
<Banner
visible={!!memeUriError}
visible={!!memeError}
actions={[
{
label: 'Cancel',
@@ -119,8 +162,9 @@ const AddMeme = ({
<View style={editorStyles.editorView}>
<MemeEditor
memeUri={memeUri}
memeUriError={memeUriError}
setMemeUriError={setMemeUriError}
memeFilename={memeFilename}
memeError={memeError}
setMemeError={setMemeError}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeTags={memeTags}
@@ -131,46 +175,31 @@ const AddMeme = ({
<Button
mode="contained-tonal"
icon="plus"
onPress={async () => {
setIsSavingAndAddingAnother(true);
await handleSave();
setIsSavingAndAddingAnother(false);
file.current = (await pickSingle({
type: allowedMimeTypes,
}).catch(goBack)) as DocumentPickerResponse;
setMemeUri(file.current.uri);
setMemeTitle(validateMemeTitle('New Meme'));
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
}}
onPress={handleSaveAndAddMore}
disabled={
!memeTitle.valid ||
isSaving ||
isSavingAndAddingAnother ||
!!memeUriError
isSavingAndAddingMore ||
!!memeError ||
!isLastFile
}
loading={isSavingAndAddingAnother}
loading={isSavingAndAddingMore}
style={editorStyles.saveAndAddButton}>
Save & Add
Save & Add More
</Button>
<Button
mode="contained"
icon="floppy"
onPress={async () => {
setIsSaving(true);
await handleSave();
setIsSaving(false);
goBack();
}}
onPress={isLastFile ? handleSave : handleSaveAndNext}
disabled={
!memeTitle.valid ||
isSaving ||
isSavingAndAddingAnother ||
!!memeUriError
isSavingAndAddingMore ||
!!memeError
}
loading={isSaving}
style={editorStyles.saveButton}>
Save
{isLastFile ? 'Save' : 'Save & Next'}
</Button>
</View>
</ScrollView>

View File

@@ -26,10 +26,9 @@ const AddTag = () => {
// Although saving tags is instantaneous, we still want to show a loading
// indicator to prevent the user from spamming the save button.
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false);
const handleSave = useCallback(() => {
const saveTag = useCallback(() => {
realm.write(() => {
realm.create(Tag.schema.name, {
name: tagName.parsed,
@@ -38,6 +37,19 @@ const AddTag = () => {
});
}, [realm, tagColor.parsed, tagName.parsed]);
const handleSave = useCallback(() => {
saveTag();
goBack();
}, [goBack, saveTag]);
const handleSaveAndAddMore = useCallback(() => {
setIsSavingAndAddingMore(true);
saveTag();
setTimeout(() => setIsSavingAndAddingMore(false), 250);
setTagName(validateTagName('newTag'));
setTagColor(validateColor(generateRandomColor()));
}, [saveTag]);
return (
<>
<Appbar.Header>
@@ -64,26 +76,17 @@ const AddTag = () => {
<Button
mode="contained-tonal"
icon="plus"
onPress={() => {
setIsSavingAndAddingAnother(true);
handleSave();
setTimeout(() => setIsSavingAndAddingAnother(false), 250);
setTagName(validateTagName('newTag'));
setTagColor(validateColor(generateRandomColor()));
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
loading={isSavingAndAddingAnother}
onPress={handleSaveAndAddMore}
disabled={!tagName.valid || isSavingAndAddingMore}
loading={isSavingAndAddingMore}
style={editorStyles.saveAndAddButton}>
Save & Add
</Button>
<Button
mode="contained"
icon="floppy"
onPress={() => {
handleSave();
goBack();
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
onPress={handleSave}
disabled={!tagName.valid || isSavingAndAddingMore}
style={editorStyles.saveButton}>
Save
</Button>

View File

@@ -9,6 +9,8 @@ import { BSON } from 'realm';
import { RootStackParamList, ROUTE } from '../../types';
import { pickSingle } from 'react-native-document-picker';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { extension } from 'react-native-mime-types';
import { useSelector } from 'react-redux';
import { Tag, Meme } from '../../database';
import {
StringValidationResult,
@@ -21,8 +23,6 @@ import {
} from '../../utilities';
import { MemeEditor } from '../../components';
import editorStyles from './editorStyles';
import { extension } from 'react-native-mime-types';
import { useSelector } from 'react-redux';
import { RootState } from '../../state';
const EditMeme = ({
@@ -42,10 +42,11 @@ const EditMeme = ({
Meme.schema.name,
BSON.UUID.createFromHexString(route.params.id),
)!;
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const [hasChanges, setHasChanges] = useState(false);
const [memeUriError, setMemeUriError] = useState<Error>();
const [memeError, setMemeError] = useState<Error>();
const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title));
const [memeTags, setMemeTags] = useState(
new Map<string, Tag>(meme.tags.map(tag => [tag.id.toHexString(), tag])),
@@ -64,6 +65,8 @@ const EditMeme = ({
const [isSaving, setIsSaving] = useState(false);
const handleSave = useCallback(() => {
setIsSaving(true);
realm.write(() => {
meme.tags.forEach(tag => {
if (!memeTags.has(tag.id.toHexString())) {
@@ -87,7 +90,17 @@ const EditMeme = ({
meme.tagsLength = memeTags.size;
meme.dateModified = new Date();
});
}, [meme, memeTags, memeTitle.parsed, realm]);
setIsSaving(false);
goBack();
}, [goBack, meme, memeTags, memeTitle.parsed, realm]);
const handleDelete = useCallback(async () => {
setIsSaving(true);
await deleteMeme(realm, storageUri, meme);
setIsSaving(false);
goBack();
}, [goBack, meme, realm, storageUri]);
const handleFixUri = useCallback(async () => {
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
@@ -101,16 +114,16 @@ const EditMeme = ({
const fileExtension = extension(mimeType) as string;
if (!fileExtension) return;
const uri = AndroidScoped.appendPath(
storageUri,
`${meme.id.toHexString()}-${Date.now() / 1000}.${fileExtension}`,
);
const filename = `${meme.id.toHexString()}-${
Date.now() / 1000
}.${fileExtension}`;
const newUri = AndroidScoped.appendPath(storageUri, filename);
await FileSystem.cp(file.uri, uri);
const { size } = await FileSystem.stat(uri);
await FileSystem.cp(file.uri, newUri);
const { size } = await FileSystem.stat(newUri);
realm.write(() => {
meme.uri = uri;
meme.filename = filename;
meme.type = memeType;
meme.mimeType = mimeType;
meme.size = size;
@@ -126,18 +139,10 @@ const EditMeme = ({
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
onPress={() => favoriteMeme(realm, meme)}
/>
<Appbar.Action
icon="delete"
onPress={async () => {
setIsSaving(true);
await deleteMeme(realm, meme);
setIsSaving(false);
goBack();
}}
/>
<Appbar.Action icon="delete" onPress={handleDelete} />
</Appbar.Header>
<Banner
visible={!!memeUriError}
visible={!!memeError}
actions={[
{
label: 'Fix URI',
@@ -145,12 +150,7 @@ const EditMeme = ({
},
{
label: 'Delete Meme',
onPress: async () => {
setIsSaving(true);
await deleteMeme(realm, meme);
setIsSaving(false);
goBack();
},
onPress: handleDelete,
},
]}>
The URI for this meme appears to be broken. This may have been caused by
@@ -166,9 +166,10 @@ const EditMeme = ({
]}>
<View style={editorStyles.editorView}>
<MemeEditor
memeUri={meme.uri}
memeUriError={memeUriError}
setMemeUriError={setMemeUriError}
memeUri={uri}
memeFilename={meme.filename}
memeError={memeError}
setMemeError={setMemeError}
memeTitle={memeTitle}
setMemeTitle={handleMemeTitleChange}
memeTags={memeTags}
@@ -179,14 +180,9 @@ const EditMeme = ({
<Button
mode="contained"
icon="floppy"
onPress={() => {
setIsSaving(true);
handleSave();
setIsSaving(false);
goBack();
}}
onPress={handleSave}
disabled={
!memeTitle.valid || !hasChanges || isSaving || !!memeUriError
!memeTitle.valid || !hasChanges || isSaving || !!memeError
}
loading={isSaving}
style={editorStyles.soloSaveButton}>

View File

@@ -51,7 +51,9 @@ const EditTag = ({
tag.color = tagColor.parsed;
tag.dateModified = new Date();
});
}, [realm, tag, tagColor.parsed, tagName.parsed]);
goBack();
}, [goBack, realm, tag, tagColor.parsed, tagName.parsed]);
return (
<>
@@ -87,10 +89,7 @@ const EditTag = ({
<Button
mode="contained"
icon="floppy"
onPress={() => {
handleSave();
goBack();
}}
onPress={handleSave}
disabled={!tagName.valid || !tagColor.valid || !hasChanges}
style={editorStyles.soloSaveButton}>
Save

View File

@@ -17,6 +17,8 @@ import {
multipleIdQuery,
shareMeme,
} from '../utilities';
import { useSelector } from 'react-redux';
import { RootState } from '../state';
const memeViewStyles = StyleSheet.create({
// eslint-disable-next-line react-native/no-color-literals
@@ -48,6 +50,10 @@ const MemeView = ({
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
const { height, width } = useSafeAreaFrame();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const realm = useRealm();
const { ids } = route.params;
@@ -97,7 +103,7 @@ const MemeView = ({
<Appbar.Action
icon="share"
onPress={() => {
shareMeme(memes[index]).catch(() => {
shareMeme(realm, storageUri, memes[index]).catch(() => {
setSnackbarMessage('Failed to share meme!');
setSnackbarVisible(true);
});
@@ -106,7 +112,7 @@ const MemeView = ({
<Appbar.Action
icon="content-copy"
onPress={async () => {
await copyMeme(memes[index])
await copyMeme(realm, storageUri, memes[index])
.then(() => {
setSnackbarMessage('Meme copied!');
setSnackbarVisible(true);
@@ -132,7 +138,7 @@ const MemeView = ({
index: index - 1,
});
}
void deleteMeme(realm, memes[index]);
void deleteMeme(realm, storageUri, memes[index]);
if (memes.length === 1) navigation.goBack();
}}
/>

View File

@@ -10,7 +10,6 @@ import {
Text,
useTheme,
} from 'react-native-paper';
import { openDocumentTree } from 'react-native-scoped-storage';
import { useDispatch, useSelector } from 'react-redux';
import type {} from 'redux-thunk/extend-redux';
import {
@@ -18,8 +17,11 @@ import {
setGridColumns,
setMasonryColumns,
setNoMedia,
setStorageUri,
} from '../state';
import StorageLocationChangeDialog from '../components/storageLocationChangeDialog';
import { useRealm } from '@realm/react';
import { FileSystem, FileStat } from 'react-native-file-access';
import { Meme } from '../database';
const settingsStyles = StyleSheet.create({
scrollView: {
@@ -51,18 +53,38 @@ const Settings = () => {
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const dispatch = useDispatch();
const realm = useRealm();
const [isOptimizingDatabase, setIsOptimizingDatabase] = useState(false);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const optimizeDatabase = () => {
setIsOptimizingDatabase(true);
// TODO: clean up missing / extra files
setSnackbarMessage('Database optimized!');
const [
storageLocationChangeDialogVisible,
setStorageLocationChangeDialogVisible,
] = useState(false);
const refreshMemeMetadata = async () => {
const stat = await FileSystem.statDir(storageUri);
const statMap = new Map<string, FileStat>();
stat.forEach(s => statMap.set(s.filename, s));
const memes = realm.objects<Meme>(Meme.schema.name);
realm.write(() => {
memes.forEach(meme => {
const fileStat = statMap.get(meme.filename);
meme.size = fileStat?.size ?? 0;
});
});
setSnackbarMessage('Meme metadata refreshed.');
setSnackbarVisible(true);
setIsOptimizingDatabase(false);
};
return (
@@ -111,15 +133,18 @@ const Settings = () => {
/>
</List.Section>
<List.Section>
<List.Subheader>Media Storage</List.Subheader>
<List.Subheader>Storage</List.Subheader>
<Button
mode="elevated"
style={settingsStyles.marginBottom}
onPress={async () => {
const { uri } = await openDocumentTree(true);
void dispatch(setStorageUri(uri));
}}>
Change External Storage Path
onPress={() => setStorageLocationChangeDialogVisible(true)}>
Change Storage Location
</Button>
<Button
mode="elevated"
style={settingsStyles.marginBottom}
onPress={refreshMemeMetadata}>
Refresh Meme Metadata
</Button>
<View style={settingsStyles.hideMediaSwitch}>
<Text>Hide media from gallery</Text>
@@ -131,17 +156,16 @@ const Settings = () => {
/>
</View>
</List.Section>
<List.Section>
<List.Subheader>Database</List.Subheader>
<Button
mode="elevated"
loading={isOptimizingDatabase}
onPress={optimizeDatabase}>
Optimize Database Now
</Button>
</List.Section>
</ScrollView>
<Portal>
<Portal>
<StorageLocationChangeDialog
visible={storageLocationChangeDialogVisible}
setVisible={setStorageLocationChangeDialogVisible}
setSnackbarVisible={setSnackbarVisible}
setSnackbarMessage={setSnackbarMessage}
/>
</Portal>
<Snackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}

View File

@@ -18,7 +18,7 @@ interface MemeViewRouteParams {
}
interface AddMemeRouteParams {
file: DocumentPickerResponse;
files: DocumentPickerResponse[];
}
interface EditMemeRouteParams {

View File

@@ -23,7 +23,10 @@ export {
editMeme,
deleteMeme,
} from './meme';
export { isPermissionForPath, clearPermissions } from './permissions';
export {
isPermissionForPath,
clearPermissions,
} from './permissions';
export { deleteTag } from './tag';
export {
type StringValidationResult,

View File

@@ -1,6 +1,5 @@
import { NavigationProp } from '@react-navigation/native';
import { Dirs, FileSystem } from 'react-native-file-access';
import { extension } from 'react-native-mime-types';
import { AndroidScoped, Dirs, FileSystem } from 'react-native-file-access';
import Share from 'react-native-share';
import Clipboard from '@react-native-clipboard/clipboard';
import { Meme } from '../database';
@@ -13,10 +12,20 @@ const favoriteMeme = (realm: Realm, meme: Meme) => {
});
};
const shareMeme = async (meme: Meme) => {
const fileExtension = extension(meme.mimeType) as string;
const cacheUri = `${Dirs.CacheDir}/${meme.id.toHexString()}.${fileExtension}`;
await FileSystem.cp(meme.uri, cacheUri);
const shareMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const cacheUri = `${Dirs.CacheDir}/${meme.filename}`;
realm.write(() => {
meme.dateUsed = new Date();
meme.timesUsed += 1;
meme.tags.forEach(tag => {
tag.dateUsed = new Date();
tag.timesUsed += 1;
});
});
await FileSystem.cp(uri, cacheUri);
await Share.open({
url: `file://${cacheUri}`,
type: meme.mimeType,
@@ -24,10 +33,22 @@ const shareMeme = async (meme: Meme) => {
});
};
const copyMeme = async (meme: Meme) => {
const exists = await FileSystem.exists(meme.uri);
const copyMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const exists = await FileSystem.exists(uri);
if (!exists) throw new Error('File does not exist');
Clipboard.setURI(meme.uri);
realm.write(() => {
meme.dateUsed = new Date();
meme.timesUsed += 1;
meme.tags.forEach(tag => {
tag.dateUsed = new Date();
tag.timesUsed += 1;
});
});
Clipboard.setURI(uri);
};
const editMeme = (
@@ -37,8 +58,10 @@ const editMeme = (
navigation.navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() });
};
const deleteMeme = async (realm: Realm, meme: Meme) => {
await FileSystem.unlink(meme.uri).catch(noOp);
const deleteMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
await FileSystem.unlink(uri).catch(noOp);
realm.write(() => {
for (const tag of meme.tags) {

View File

@@ -4,7 +4,7 @@ import {
} from 'react-native-scoped-storage';
const isPermissionForPath = (permission: string, path: string) => {
return path.startsWith(permission + '/');
return path.startsWith(permission + '/') || path === permission;
};
const clearPermissions = async (excepts: string[] = []) => {