diff --git a/src/components/loadingView.tsx b/src/components/loadingView.tsx index 1967e42..3d3a111 100644 --- a/src/components/loadingView.tsx +++ b/src/components/loadingView.tsx @@ -7,8 +7,6 @@ const loadingViewStyles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', flex: 1, - width: '100%', - height: '100%', }, }); diff --git a/src/components/memes/index.ts b/src/components/memes/index.ts index 116868f..97bc873 100644 --- a/src/components/memes/index.ts +++ b/src/components/memes/index.ts @@ -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'; diff --git a/src/components/memes/memeEditor.tsx b/src/components/memes/memeEditor.tsx index 9fcd465..d811de1 100644 --- a/src/components/memes/memeEditor.tsx +++ b/src/components/memes/memeEditor.tsx @@ -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; @@ -37,8 +41,9 @@ const MemeEditor = ({ const { width } = useSafeAreaFrame(); const { dimensions, loading, error } = useImageDimensions({ uri: memeUri }); + setMemeUriError(error); - if (loading || error || !dimensions) return ; + if (!memeUriError && (loading || !dimensions)) return ; return ( <> @@ -53,23 +58,36 @@ const MemeEditor = ({ {memeTitle.error} - + ) : ( + + }, + memeEditorStyles.image, + ]} + resizeMode="contain" + /> + )} ) => { + const { colors } = useTheme(); + + return ( + + + + ); +}; + +export default MemeFail; diff --git a/src/components/memes/memeViewItem.tsx b/src/components/memes/memeViewItem.tsx index 2b54d4a..3a35358 100644 --- a/src/components/memes/memeViewItem.tsx +++ b/src/components/memes/memeViewItem.tsx @@ -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 ; + if (!error && (loading || !dimensions)) { + return ( + + + + ); + } return ( - 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 ? ( + + ) : ( + width / (height - 128) + ? { + width, + height: width / (dimensions.width / dimensions.height), + } + : { + width: + (height - 128) * (dimensions.width / dimensions.height), + height: height - 128, + } + } + minScale={0.5} + /> + )} ); }; diff --git a/src/components/memes/memesList/memesGridItem.tsx b/src/components/memes/memesList/memesGridItem.tsx index d6acb22..30516e6 100644 --- a/src/components/memes/memesList/memesGridItem.tsx +++ b/src/components/memes/memesList/memesGridItem.tsx @@ -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 ( focusMeme(index)}> - + }} + iconSize={getFontAwesome5IconSize(gridColumns)} + /> + ) : ( + + )} ); }; diff --git a/src/components/memes/memesList/memesListItem.tsx b/src/components/memes/memesList/memesListItem.tsx index 2b53900..feec115 100644 --- a/src/components/memes/memesList/memesListItem.tsx +++ b/src/components/memes/memesList/memesListItem.tsx @@ -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 ( focusMeme(index)} style={memesListItemStyles.view}> <> - + {error ? ( + + ) : ( + + )} ; + if (!error && (loading || !dimensions)) return <>; return ( focusMeme(index)} style={memeMasonryItemStyles.view}> - + {error || !dimensions ? ( + + ) : ( + + )} ); }; diff --git a/src/screens/editors/addMeme.tsx b/src/screens/editors/addMeme.tsx index 032976d..9682096 100644 --- a/src/screens/editors/addMeme.tsx +++ b/src/screens/editors/addMeme.tsx @@ -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(); const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme')); const [memeIsFavorite, setMemeIsFavorite] = useState(false); const [memeTags, setMemeTags] = useState(new Map()); @@ -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)} /> + + The selected URI appears to be broken. This may have been caused by the + file being corrupted or unsupported. + ()); }} - 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 diff --git a/src/screens/editors/editMeme.tsx b/src/screens/editors/editMeme.tsx index 3c69f8c..1ddfa1e 100644 --- a/src/screens/editors/editMeme.tsx +++ b/src/screens/editors/editMeme.tsx @@ -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( @@ -26,11 +43,24 @@ const EditMeme = ({ BSON.UUID.createFromHexString(route.params.id), )!; + const [hasChanges, setHasChanges] = useState(false); + + const [memeUriError, setMemeUriError] = useState(); const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title)); const [memeTags, setMemeTags] = useState( new Map(meme.tags.map(tag => [tag.id.toHexString(), tag])), ); + const handleMemeTitleChange = useCallback((title: StringValidationResult) => { + setMemeTitle(title); + setHasChanges(true); + }, []); + + const handleMemeTagsChange = useCallback((tags: Map) => { + 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 ( <> @@ -78,6 +136,26 @@ const EditMeme = ({ }} /> + { + 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. + @@ -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 diff --git a/src/screens/editors/editTag.tsx b/src/screens/editors/editTag.tsx index 6d350fb..4a01055 100644 --- a/src/screens/editors/editTag.tsx +++ b/src/screens/editors/editTag.tsx @@ -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 = ({ @@ -75,7 +91,8 @@ const EditTag = ({ handleSave(); goBack(); }} - disabled={!tagName.valid || !tagColor.valid}> + disabled={!tagName.valid || !tagColor.valid || !hasChanges} + style={editorStyles.soloSaveButton}> Save diff --git a/src/screens/editors/editorStyles.ts b/src/screens/editors/editorStyles.ts index 78f0fbe..30a88fd 100644 --- a/src/screens/editors/editorStyles.ts +++ b/src/screens/editors/editorStyles.ts @@ -29,6 +29,9 @@ const editorStyles = StyleSheet.create({ flex: 1, marginLeft: 5, }, + soloSaveButton: { + flex: 1, + }, }); export default editorStyles; diff --git a/src/screens/memeView.tsx b/src/screens/memeView.tsx index a01feed..8684c25 100644 --- a/src/screens/memeView.tsx +++ b/src/screens/memeView.tsx @@ -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])} /> - shareMeme(memes[index])} /> + { + shareMeme(memes[index]).catch(() => { + setSnackbarMessage('Failed to share meme!'); + setSnackbarVisible(true); + }); + }} + /> { - 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); + }); }} /> { - 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 }; diff --git a/src/utilities/filesystem.ts b/src/utilities/filesystem.ts index e619627..ebc8581 100644 --- a/src/utilities/filesystem.ts +++ b/src/utilities/filesystem.ts @@ -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': diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 6029971..5bc0843 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -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, diff --git a/src/utilities/meme.ts b/src/utilities/meme.ts index 1ce1764..611b6e1 100644 --- a/src/utilities/meme.ts +++ b/src/utilities/meme.ts @@ -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) {