diff --git a/android/app/build.gradle b/android/app/build.gradle
index 70803aa..f0d8646 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -79,7 +79,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
- versionName "1.0"
+ versionName "0.0.3"
}
signingConfigs {
debug {
diff --git a/package-lock.json b/package-lock.json
index e997a8d..4ebc6e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 2f86d06..d376534 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@karaolidis/terminally-online",
- "version": "0.0.2",
+ "version": "0.0.3",
"private": true,
"scripts": {
"postinstall": "patch-package",
diff --git a/src/app.tsx b/src/app.tsx
index 8a38997..a720ecf 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -54,13 +54,13 @@ const App = () => {
}, []);
return (
-
-
- }
- persistor={persistor}
- onBeforeLift={onBeforeLift}>
-
+
+
+
+ }
+ persistor={persistor}
+ onBeforeLift={onBeforeLift}>
{
)}
-
-
-
-
+
+
+
+
);
};
diff --git a/src/components/floatingActionButton.tsx b/src/components/floatingActionButton.tsx
index 8f2deed..9a5d3b1 100644
--- a/src/components/floatingActionButton.tsx
+++ b/src/components/floatingActionButton.tsx
@@ -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'
diff --git a/src/components/index.ts b/src/components/index.ts
index c6aa651..f4db135 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -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';
diff --git a/src/components/memes/memeEditor.tsx b/src/components/memes/memeEditor.tsx
index ca3b439..185927f 100644
--- a/src/components/memes/memeEditor.tsx
+++ b/src/components/memes/memeEditor.tsx
@@ -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;
setMemeTags: (tags: Map) => 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 ;
+ if (!memeError && (loading || !dimensions)) return ;
return (
<>
@@ -58,7 +65,7 @@ const MemeEditor = ({
{memeTitle.error}
- {memeUriError || !dimensions ? (
+ {memeError || !dimensions ? (
)}
+
+ {memeFilename}
+
{
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 }) => {
/>
) : (
width / (height - 128)
? {
diff --git a/src/components/memes/memesList/memesGridItem.tsx b/src/components/memes/memesList/memesGridItem.tsx
index 30516e6..4f6e1df 100644
--- a/src/components/memes/memesList/memesGridItem.tsx
+++ b/src/components/memes/memesList/memesGridItem.tsx
@@ -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 = ({
/>
) : (
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 ? (
) : (
-
+
)}
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 = ({
/>
) : (
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 (
+
+ );
+};
+
+export default StorageLocationChangeDialog;
diff --git a/src/database/meme.ts b/src/database/meme.ts
index 67df4e6..bca91c8 100644
--- a/src/database/meme.ts
+++ b/src/database/meme.ts
@@ -21,7 +21,7 @@ const memeTypePlural = {
class Meme extends Object {
id!: BSON.UUID;
type!: MEME_TYPE;
- uri!: string;
+ filename!: string;
mimeType!: string;
size!: number;
title!: string;
@@ -39,7 +39,7 @@ class Meme extends Object {
properties: {
id: { type: 'uuid', default: () => new BSON.UUID() },
type: { type: 'string', indexed: true },
- uri: 'string',
+ filename: 'string',
mimeType: 'string',
size: 'int',
title: 'string',
diff --git a/src/screens/editors/addMeme.tsx b/src/screens/editors/addMeme.tsx
index 9682096..7ecce50 100644
--- a/src/screens/editors/addMeme.tsx
+++ b/src/screens/editors/addMeme.tsx
@@ -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();
+ const [memeFilename, setMemeFilename] = useState(
+ file.current.name ?? undefined,
+ );
+ const [memeError, setMemeError] = useState();
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map());
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.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());
+ }, [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());
+ }, [goBack, saveMeme]);
+
return (
<>
@@ -98,7 +141,7 @@ const AddMeme = ({
/>
{
- 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());
- }}
+ onPress={handleSaveAndAddMore}
disabled={
!memeTitle.valid ||
isSaving ||
- isSavingAndAddingAnother ||
- !!memeUriError
+ isSavingAndAddingMore ||
+ !!memeError ||
+ !isLastFile
}
- loading={isSavingAndAddingAnother}
+ loading={isSavingAndAddingMore}
style={editorStyles.saveAndAddButton}>
- Save & Add
+ Save & Add More
diff --git a/src/screens/editors/addTag.tsx b/src/screens/editors/addTag.tsx
index 9716b23..6f8c16b 100644
--- a/src/screens/editors/addTag.tsx
+++ b/src/screens/editors/addTag.tsx
@@ -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 (
<>
@@ -64,26 +76,17 @@ const AddTag = () => {
diff --git a/src/screens/editors/editMeme.tsx b/src/screens/editors/editMeme.tsx
index 1ddfa1e..795c2b7 100644
--- a/src/screens/editors/editMeme.tsx
+++ b/src/screens/editors/editMeme.tsx
@@ -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();
+ const [memeError, setMemeError] = useState();
const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title));
const [memeTags, setMemeTags] = useState(
new Map(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)}
/>
- {
- setIsSaving(true);
- await deleteMeme(realm, meme);
- setIsSaving(false);
- goBack();
- }}
- />
+
{
- 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 = ({
]}>
{
- setIsSaving(true);
- handleSave();
- setIsSaving(false);
- goBack();
- }}
+ onPress={handleSave}
disabled={
- !memeTitle.valid || !hasChanges || isSaving || !!memeUriError
+ !memeTitle.valid || !hasChanges || isSaving || !!memeError
}
loading={isSaving}
style={editorStyles.soloSaveButton}>
diff --git a/src/screens/editors/editTag.tsx b/src/screens/editors/editTag.tsx
index 4a01055..b9a087c 100644
--- a/src/screens/editors/editTag.tsx
+++ b/src/screens/editors/editTag.tsx
@@ -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 = ({