Add broken URI handling

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-26 16:10:45 +03:00
parent 083e798fdf
commit acba17462f
17 changed files with 370 additions and 91 deletions

View File

@@ -7,8 +7,6 @@ const loadingViewStyles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
flex: 1,
width: '100%',
height: '100%',
},
});

View File

@@ -1,5 +1,6 @@
export { default as MemesList } from './memesList/memesList';
export { default as MemeEditor } from './memeEditor';
export { default as MemeFail } from './memeFail';
export { default as MemesHeader } from './memesHeader';
export { default as MemeTagSearchModal } from './memeTagSearchModal';
export { default as MemeTagSelector } from './memeTagSelector';

View File

@@ -1,12 +1,12 @@
import React from 'react';
import React, { useEffect } from 'react';
import { HelperText, TextInput } 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';
import LoadingView from '../loadingView';
import { MemeTagSelector } from '.';
import { MemeFail, MemeTagSelector } from '.';
import { Tag } from '../../database';
import { StringValidationResult, validateMemeTitle } from '../../utilities';
import { useImageDimensions } from '@react-native-community/hooks';
const memeEditorStyles = {
image: {
@@ -23,12 +23,16 @@ const memeEditorStyles = {
const MemeEditor = ({
memeUri,
memeUriError,
setMemeUriError,
memeTitle,
setMemeTitle,
memeTags,
setMemeTags,
}: {
memeUri: string;
memeUriError: Error | undefined;
setMemeUriError: (error: Error | undefined) => void;
memeTitle: StringValidationResult;
setMemeTitle: (name: StringValidationResult) => void;
memeTags: Map<string, Tag>;
@@ -37,8 +41,9 @@ const MemeEditor = ({
const { width } = useSafeAreaFrame();
const { dimensions, loading, error } = useImageDimensions({ uri: memeUri });
setMemeUriError(error);
if (loading || error || !dimensions) return <LoadingView />;
if (!memeUriError && (loading || !dimensions)) return <LoadingView />;
return (
<>
@@ -53,23 +58,36 @@ const MemeEditor = ({
<HelperText type="error" visible={!memeTitle.valid}>
{memeTitle.error}
</HelperText>
<Image
source={{ uri: memeUri }}
style={[
{
width: width * 0.92,
height: Math.max(
Math.min(
((width * 0.92) / dimensions.width) * dimensions.height,
500,
{memeUriError || !dimensions ? (
<MemeFail
style={[
{
width: width * 0.92,
height: width * 0.92,
},
memeEditorStyles.image,
]}
iconSize={50}
/>
) : (
<Image
source={{ uri: memeUri }}
style={[
{
width: width * 0.92,
height: Math.max(
Math.min(
((width * 0.92) / dimensions.width) * dimensions.height,
500,
),
100,
),
100,
),
},
memeEditorStyles.image,
]}
resizeMode="contain"
/>
},
memeEditorStyles.image,
]}
resizeMode="contain"
/>
)}
<MemeTagSelector
memeTags={memeTags}
setMemeTags={setMemeTags}

View File

@@ -0,0 +1,41 @@
import React, { ComponentProps } from 'react';
import { StyleSheet, View } from 'react-native';
import { useTheme } from 'react-native-paper';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { rgbToRgba } from '../../utilities';
const memeFailStyles = StyleSheet.create({
view: {
alignItems: 'center',
justifyContent: 'center',
},
});
const MemeFail = ({
iconSize,
...props
}: {
iconSize?: number;
} & ComponentProps<typeof View>) => {
const { colors } = useTheme();
return (
<View
{...props}
style={[
props.style,
memeFailStyles.view,
{
backgroundColor: rgbToRgba(colors.error, 0.2),
},
]}>
<FontAwesome5
name="exclamation-triangle"
size={iconSize}
color={colors.error}
/>
</View>
);
};
export default MemeFail;

View File

@@ -5,6 +5,7 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import LoadingView from '../loadingView';
import { Meme } from '../../database';
import MemeFail from './memeFail';
const memeViewItemStyles = StyleSheet.create({
view: {
@@ -18,25 +19,42 @@ const MemeViewItem = ({ meme }: { meme: Meme }) => {
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
if (loading || error || !dimensions) return <LoadingView />;
if (!error && (loading || !dimensions)) {
return (
<View style={{ width, height }}>
<LoadingView />
</View>
);
}
return (
<View style={[{ width, height }, memeViewItemStyles.view]}>
<ImageZoom
source={{ uri: meme.uri }}
style={
dimensions.aspectRatio > width / (height - 128)
? {
width,
height: width / (dimensions.width / dimensions.height),
}
: {
width: (height - 128) * (dimensions.width / dimensions.height),
height: height - 128,
}
}
minScale={0.5}
/>
{error || !dimensions ? (
<MemeFail
style={{
width: Math.min(width, height - 128),
height: Math.min(width, height - 128),
}}
iconSize={50}
/>
) : (
<ImageZoom
source={{ uri: meme.uri }}
style={
dimensions.aspectRatio > width / (height - 128)
? {
width,
height: width / (dimensions.width / dimensions.height),
}
: {
width:
(height - 128) * (dimensions.width / dimensions.height),
height: height - 128,
}
}
minScale={0.5}
/>
)}
</View>
);
};

View File

@@ -5,6 +5,8 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { MemeFail } from '..';
import { getFontAwesome5IconSize } from '../../../utilities';
const MemesGridItem = ({
meme,
@@ -22,19 +24,29 @@ const MemesGridItem = ({
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
if (loading || error || !dimensions) return <></>;
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableHighlight onPress={() => focusMeme(index)}>
<Image
source={{ uri: meme.uri }}
style={[
{
{error ? (
<MemeFail
style={{
width: (width * 0.92 - 5) / gridColumns,
height: (width * 0.92 - 5) / gridColumns,
},
]}
/>
}}
iconSize={getFontAwesome5IconSize(gridColumns)}
/>
) : (
<Image
source={{ uri: meme.uri }}
style={[
{
width: (width * 0.92 - 5) / gridColumns,
height: (width * 0.92 - 5) / gridColumns,
},
]}
/>
)}
</TouchableHighlight>
);
};

View File

@@ -4,6 +4,7 @@ import { Text, TouchableRipple } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import { Meme } from '../../../database';
import { MemeFail } from '..';
const memesListItemStyles = StyleSheet.create({
view: {
@@ -42,14 +43,18 @@ const MemesListItem = ({
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
if (loading || error || !dimensions) return <></>;
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableRipple
onPress={() => focusMeme(index)}
style={memesListItemStyles.view}>
<>
<Image source={{ uri: meme.uri }} style={memesListItemStyles.image} />
{error ? (
<MemeFail style={memesListItemStyles.image} />
) : (
<Image source={{ uri: meme.uri }} style={memesListItemStyles.image} />
)}
<View
style={[
memesListItemStyles.detailsView,

View File

@@ -5,6 +5,8 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { useImageDimensions } from '@react-native-community/hooks';
import { MemeFail } from '..';
import { getFontAwesome5IconSize } from '../../../utilities';
const memeMasonryItemStyles = StyleSheet.create({
view: {
@@ -32,23 +34,36 @@ const MemesMasonryItem = ({
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
if (loading || error || !dimensions) return <></>;
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableHighlight
onPress={() => focusMeme(index)}
style={memeMasonryItemStyles.view}>
<Image
source={{ uri: meme.uri }}
style={[
memeMasonryItemStyles.image,
{
width: (width * 0.92) / masonryColumns - 5,
height:
((width * 0.92) / masonryColumns - 5) / dimensions.aspectRatio,
},
]}
/>
{error || !dimensions ? (
<MemeFail
style={[
memeMasonryItemStyles.image,
{
width: (width * 0.92) / masonryColumns - 5,
height: (width * 0.92) / masonryColumns - 5,
},
]}
iconSize={getFontAwesome5IconSize(masonryColumns)}
/>
) : (
<Image
source={{ uri: meme.uri }}
style={[
memeMasonryItemStyles.image,
{
width: (width * 0.92) / masonryColumns - 5,
height:
((width * 0.92) / masonryColumns - 5) / dimensions.aspectRatio,
},
]}
/>
)}
</TouchableHighlight>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useState } from 'react';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { Appbar, Banner, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { ScrollView, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -39,6 +39,7 @@ const AddMeme = ({
const file = useRef(route.params.file);
const [memeUri, setMemeUri] = useState(file.current.uri);
const [memeUriError, setMemeUriError] = useState<Error>();
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
@@ -51,14 +52,15 @@ const AddMeme = ({
const uuid = new BSON.UUID();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mimeType = file.current.type!;
const memeType = getMemeType(mimeType);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const memeType = getMemeType(mimeType)!;
const fileExtension = extension(mimeType) as string;
if (!fileExtension) goBack();
const uri = AndroidScoped.appendPath(
storageUri,
`${uuid.toHexString()}.${fileExtension}`,
`${uuid.toHexString()}-${Math.round(Date.now() / 1000)}.${fileExtension}`,
);
await FileSystem.cp(file.current.uri, uri);
@@ -95,6 +97,17 @@ const AddMeme = ({
onPress={() => setMemeIsFavorite(!memeIsFavorite)}
/>
</Appbar.Header>
<Banner
visible={!!memeUriError}
actions={[
{
label: 'Cancel',
onPress: goBack,
},
]}>
The selected URI appears to be broken. This may have been caused by the
file being corrupted or unsupported.
</Banner>
<ScrollView
contentContainerStyle={[
editorStyles.scrollView,
@@ -106,6 +119,8 @@ const AddMeme = ({
<View style={editorStyles.editorView}>
<MemeEditor
memeUri={memeUri}
memeUriError={memeUriError}
setMemeUriError={setMemeUriError}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeTags={memeTags}
@@ -128,7 +143,12 @@ const AddMeme = ({
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
}}
disabled={!memeTitle.valid || isSaving || isSavingAndAddingAnother}
disabled={
!memeTitle.valid ||
isSaving ||
isSavingAndAddingAnother ||
!!memeUriError
}
loading={isSavingAndAddingAnother}
style={editorStyles.saveAndAddButton}>
Save & Add
@@ -142,7 +162,12 @@ const AddMeme = ({
setIsSaving(false);
goBack();
}}
disabled={!memeTitle.valid || isSaving || isSavingAndAddingAnother}
disabled={
!memeTitle.valid ||
isSaving ||
isSavingAndAddingAnother ||
!!memeUriError
}
loading={isSaving}
style={editorStyles.saveButton}>
Save

View File

@@ -1,16 +1,29 @@
import React, { useCallback, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { Appbar, Banner, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks';
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 { Tag, Meme } from '../../database';
import { deleteMeme, favoriteMeme, validateMemeTitle } from '../../utilities';
import {
StringValidationResult,
allowedMimeTypes,
deleteMeme,
favoriteMeme,
getMemeType,
noOp,
validateMemeTitle,
} 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 = ({
route,
@@ -19,6 +32,10 @@ const EditMeme = ({
const { colors } = useTheme();
const orientation = useDeviceOrientation();
const realm = useRealm();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const meme = useObject<Meme>(
@@ -26,11 +43,24 @@ const EditMeme = ({
BSON.UUID.createFromHexString(route.params.id),
)!;
const [hasChanges, setHasChanges] = useState(false);
const [memeUriError, setMemeUriError] = 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])),
);
const handleMemeTitleChange = useCallback((title: StringValidationResult) => {
setMemeTitle(title);
setHasChanges(true);
}, []);
const handleMemeTagsChange = useCallback((tags: Map<string, Tag>) => {
setMemeTags(tags);
setHasChanges(true);
}, []);
const [isSaving, setIsSaving] = useState(false);
const handleSave = useCallback(() => {
@@ -59,6 +89,34 @@ const EditMeme = ({
});
}, [meme, memeTags, memeTitle.parsed, realm]);
const handleFixUri = useCallback(async () => {
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
if (!file) return;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mimeType = file.type!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const memeType = getMemeType(mimeType)!;
const fileExtension = extension(mimeType) as string;
if (!fileExtension) return;
const uri = AndroidScoped.appendPath(
storageUri,
`${meme.id.toHexString()}-${Date.now() / 1000}.${fileExtension}`,
);
await FileSystem.cp(file.uri, uri);
const { size } = await FileSystem.stat(uri);
realm.write(() => {
meme.uri = uri;
meme.type = memeType;
meme.mimeType = mimeType;
meme.size = size;
});
}, [meme, realm, storageUri]);
return (
<>
<Appbar.Header>
@@ -78,6 +136,26 @@ const EditMeme = ({
}}
/>
</Appbar.Header>
<Banner
visible={!!memeUriError}
actions={[
{
label: 'Fix URI',
onPress: handleFixUri,
},
{
label: 'Delete Meme',
onPress: async () => {
setIsSaving(true);
await deleteMeme(realm, meme);
setIsSaving(false);
goBack();
},
},
]}>
The URI for this meme appears to be broken. This may have been caused by
the file being moved or deleted.
</Banner>
<ScrollView
contentContainerStyle={[
editorStyles.scrollView,
@@ -89,10 +167,12 @@ const EditMeme = ({
<View style={editorStyles.editorView}>
<MemeEditor
memeUri={meme.uri}
memeUriError={memeUriError}
setMemeUriError={setMemeUriError}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
setMemeTitle={handleMemeTitleChange}
memeTags={memeTags}
setMemeTags={setMemeTags}
setMemeTags={handleMemeTagsChange}
/>
</View>
<View style={editorStyles.saveButtonView}>
@@ -105,8 +185,11 @@ const EditMeme = ({
setIsSaving(false);
goBack();
}}
disabled={!memeTitle.valid || isSaving}
loading={isSaving}>
disabled={
!memeTitle.valid || !hasChanges || isSaving || !!memeUriError
}
loading={isSaving}
style={editorStyles.soloSaveButton}>
Save
</Button>
</View>

View File

@@ -9,7 +9,12 @@ import { useDeviceOrientation } from '@react-native-community/hooks';
import { TagEditor } from '../../components';
import { ROUTE, RootStackParamList } from '../../types';
import { Tag } from '../../database';
import { deleteTag, validateColor, validateTagName } from '../../utilities';
import {
StringValidationResult,
deleteTag,
validateColor,
validateTagName,
} from '../../utilities';
import editorStyles from './editorStyles';
const EditTag = ({
@@ -26,9 +31,20 @@ const EditTag = ({
BSON.UUID.createFromHexString(route.params.id),
)!;
const [hasChanges, setHasChanges] = useState(false);
const [tagName, setTagName] = useState(validateTagName(tag.name));
const [tagColor, setTagColor] = useState(validateColor(tag.color));
const handleTagNameChange = useCallback((name: StringValidationResult) => {
setTagName(name);
setHasChanges(true);
}, []);
const handleTagColorChange = useCallback((color: StringValidationResult) => {
setTagColor(color);
setHasChanges(true);
}, []);
const handleSave = useCallback(() => {
realm.write(() => {
tag.name = tagName.parsed;
@@ -62,9 +78,9 @@ const EditTag = ({
<View style={editorStyles.editorView}>
<TagEditor
tagName={tagName}
setTagName={setTagName}
setTagName={handleTagNameChange}
tagColor={tagColor}
setTagColor={setTagColor}
setTagColor={handleTagColorChange}
/>
</View>
<View style={editorStyles.saveButtonView}>
@@ -75,7 +91,8 @@ const EditTag = ({
handleSave();
goBack();
}}
disabled={!tagName.valid || !tagColor.valid}>
disabled={!tagName.valid || !tagColor.valid || !hasChanges}
style={editorStyles.soloSaveButton}>
Save
</Button>
</View>

View File

@@ -29,6 +29,9 @@ const editorStyles = StyleSheet.create({
flex: 1,
marginLeft: 5,
},
soloSaveButton: {
flex: 1,
},
});
export default editorStyles;

View File

@@ -5,6 +5,7 @@ import { useQuery, useRealm } from '@realm/react';
import { FlashList } from '@shopify/flash-list';
import { Appbar, Portal, Snackbar } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList, ROUTE } from '../types';
import { Meme } from '../database';
import { MemeViewItem } from '../components';
@@ -16,7 +17,6 @@ import {
multipleIdQuery,
shareMeme,
} from '../utilities';
import { NavigationProp, useNavigation } from '@react-navigation/native';
const memeViewStyles = StyleSheet.create({
// eslint-disable-next-line react-native/no-color-literals
@@ -94,13 +94,27 @@ const MemeView = ({
icon={memes[index].isFavorite ? 'heart' : 'heart-outline'}
onPress={() => favoriteMeme(realm, memes[index])}
/>
<Appbar.Action icon="share" onPress={() => shareMeme(memes[index])} />
<Appbar.Action
icon="share"
onPress={() => {
shareMeme(memes[index]).catch(() => {
setSnackbarMessage('Failed to share meme!');
setSnackbarVisible(true);
});
}}
/>
<Appbar.Action
icon="content-copy"
onPress={() => {
copyMeme(memes[index]);
setSnackbarMessage('Meme copied!');
setSnackbarVisible(true);
onPress={async () => {
await copyMeme(memes[index])
.then(() => {
setSnackbarMessage('Meme copied!');
setSnackbarVisible(true);
})
.catch(() => {
setSnackbarMessage('Failed to copy meme!');
setSnackbarVisible(true);
});
}}
/>
<Appbar.Action

View File

@@ -1,9 +1,35 @@
const getFlashListItemHeight = (numColumns: number) => {
const A = 500;
const B = 300;
const C = 1;
const height = A - B * Math.log(numColumns + C);
return Math.max(Math.round(height), 0);
switch (numColumns) {
case 1: {
return 350;
}
case 2: {
return 180;
}
case 3: {
return 120;
}
case 4: {
return 90;
}
}
};
export { getFlashListItemHeight };
const getFontAwesome5IconSize = (numColumns: number) => {
switch (numColumns) {
case 1: {
return 40;
}
case 2: {
return 30;
}
case 3: {
return 20;
}
case 4: {
return 14;
}
}
};
export { getFlashListItemHeight, getFontAwesome5IconSize };

View File

@@ -11,7 +11,7 @@ const allowedGifMimeTypes = ['image/gif'];
const allowedMimeTypes = [...allowedImageMimeTypes, ...allowedGifMimeTypes];
const getMemeType = (mimeType: string) => {
const getMemeType = (mimeType: string): MEME_TYPE | undefined => {
switch (mimeType) {
case 'image/bmp':
case 'image/jpeg':

View File

@@ -8,7 +8,7 @@ export {
} from './color';
export { packageName, appName, fileProvider, noOp } from './constants';
export { multipleIdQuery } from './database';
export { getFlashListItemHeight } from './dimensions';
export { getFlashListItemHeight, getFontAwesome5IconSize } from './dimensions';
export {
allowedImageMimeTypes,
allowedGifMimeTypes,

View File

@@ -5,6 +5,7 @@ import Share from 'react-native-share';
import Clipboard from '@react-native-clipboard/clipboard';
import { Meme } from '../database';
import { ROUTE, RootStackParamList } from '../types';
import { noOp } from './constants';
const favoriteMeme = (realm: Realm, meme: Meme) => {
realm.write(() => {
@@ -23,7 +24,9 @@ const shareMeme = async (meme: Meme) => {
});
};
const copyMeme = (meme: Meme) => {
const copyMeme = async (meme: Meme) => {
const exists = await FileSystem.exists(meme.uri);
if (!exists) throw new Error('File does not exist');
Clipboard.setURI(meme.uri);
};
@@ -35,7 +38,7 @@ const editMeme = (
};
const deleteMeme = async (realm: Realm, meme: Meme) => {
await FileSystem.unlink(meme.uri);
await FileSystem.unlink(meme.uri).catch(noOp);
realm.write(() => {
for (const tag of meme.tags) {