From f1f969c8ea6476a03f11fdd77a1afafc724150f1 Mon Sep 17 00:00:00 2001 From: Nikolaos Karaolidis Date: Tue, 1 Aug 2023 08:58:35 +0300 Subject: [PATCH] Refactor editor architecture Signed-off-by: Nikolaos Karaolidis --- src/components/memes/memeEditor.tsx | 81 ++++++++++-------- src/components/tags/tagEditor.tsx | 57 +++++++------ src/database/meme.ts | 4 +- src/screens/editors/addMeme.tsx | 119 +++++++++++++------------- src/screens/editors/addTag.tsx | 32 ++++--- src/screens/editors/editMeme.tsx | 127 +++++++++++++++++----------- src/screens/editors/editTag.tsx | 48 ++++------- src/types/index.ts | 1 + src/types/staging.ts | 15 ++++ src/utilities/filesystem.ts | 5 +- 10 files changed, 265 insertions(+), 224 deletions(-) create mode 100644 src/types/staging.ts diff --git a/src/components/memes/memeEditor.tsx b/src/components/memes/memeEditor.tsx index a3cff6b..5bbeca6 100644 --- a/src/components/memes/memeEditor.tsx +++ b/src/components/memes/memeEditor.tsx @@ -1,11 +1,10 @@ import React, { useState } from 'react'; import { HelperText, Text, TextInput, useTheme } from 'react-native-paper'; -import { Image } from 'react-native'; +import { Image, LayoutAnimation } from 'react-native'; import { useSafeAreaFrame } from 'react-native-safe-area-context'; -import { MemeFail, MemeTagSelector } from '..'; -import { Tag } from '../../database'; -import { StringValidationResult, validateMemeTitle } from '../../utilities'; -import { Dimensions } from '../../types'; +import { LoadingView, MemeFail, MemeTagSelector } from '..'; +import { getFilenameFromUri, validateMemeTitle } from '../../utilities'; +import { Dimensions, StagingMeme } from '../../types'; const memeEditorStyles = { image: { @@ -25,43 +24,46 @@ const memeEditorStyles = { }; const MemeEditor = ({ - memeUri, - memeFilename, - memeError, - setMemeError, - memeTitle, - setMemeTitle, - memeTags, - setMemeTags, + uri, + mimeType, + setLoading, + error, + setError, + staging, + setStaging, }: { - memeUri: string; - memeFilename?: string; - memeError: Error | undefined; - setMemeError: (error: Error | undefined) => void; - memeTitle: StringValidationResult; - setMemeTitle: (name: StringValidationResult) => void; - memeTags: Map; - setMemeTags: (tags: Map) => void; + uri?: string; + mimeType?: string; + loading: boolean; + setLoading: (loading: boolean) => void; + error: Error | undefined; + setError: (error: Error | undefined) => void; + staging?: StagingMeme; + setStaging: (staging: StagingMeme) => void; }) => { const { width } = useSafeAreaFrame(); const { colors } = useTheme(); const [dimensions, setDimensions] = useState(); + if (!uri || !mimeType || !staging) return ; + return ( <> setMemeTitle(validateMemeTitle(title))} - error={!memeTitle.valid} + value={staging.title.raw} + onChangeText={title => + setStaging({ ...staging, title: validateMemeTitle(title) }) + } + error={!staging.title.valid} selectTextOnFocus /> - - {memeTitle.error} + + {staging.title.error} - {memeError ? ( + {error ? ( ) : ( setMemeError(event.nativeEvent.error as Error)} + onError={() => + setError( + new Error( + 'The URI for this meme appears to be broken. This may have been caused by the file being moved or deleted.', + ), + ) + } /> )} - {memeFilename} + {getFilenameFromUri(uri)} setStaging({ ...staging, tags })} style={memeEditorStyles.memeTagSelector} /> diff --git a/src/components/tags/tagEditor.tsx b/src/components/tags/tagEditor.tsx index 966bab7..3c71344 100644 --- a/src/components/tags/tagEditor.tsx +++ b/src/components/tags/tagEditor.tsx @@ -2,62 +2,69 @@ import React, { useEffect, useRef } from 'react'; import { HelperText, TextInput } from 'react-native-paper'; import TagPreview from './tagPreview'; import { - StringValidationResult, generateRandomColor, validateColor, validateTagName, } from '../../utilities'; +import { StagingTag } from '../../types'; const TagEditor = ({ - tagName, - setTagName, - tagColor, - setTagColor, + staging, + setStaging, }: { - tagName: StringValidationResult; - setTagName: (name: StringValidationResult) => void; - tagColor: StringValidationResult; - setTagColor: (color: StringValidationResult) => void; + staging: StagingTag; + setStaging: (staging: StagingTag) => void; }) => { - const lastValidTagColor = useRef(tagColor.parsed); + const lastValidColor = useRef(staging.color.parsed); useEffect(() => { - if (tagColor.valid) lastValidTagColor.current = tagColor.parsed; - }, [tagColor]); + if (staging.color.valid) lastValidColor.current = staging.color.parsed; + }, [staging.color.parsed, staging.color.valid]); return ( <> setTagName(validateTagName(name))} - error={!tagName.valid} + value={staging.name.raw} + onChangeText={name => + setStaging({ ...staging, name: validateTagName(name) }) + } + error={!staging.name.valid} selectTextOnFocus /> - - {tagName.error} + + {staging.name.error} setTagColor(validateColor(color))} - error={!tagColor.valid} + value={staging.color.raw} + onChangeText={color => + setStaging({ ...staging, color: validateColor(color) }) + } + error={!staging.color.valid} autoCorrect={false} right={ setTagColor(validateColor(generateRandomColor()))} + onPress={() => + setStaging({ + ...staging, + color: validateColor(generateRandomColor()), + }) + } /> } /> - - {tagColor.error} + + {staging.color.error} ); diff --git a/src/database/meme.ts b/src/database/meme.ts index ccf55b2..134e416 100644 --- a/src/database/meme.ts +++ b/src/database/meme.ts @@ -19,8 +19,8 @@ const memeTypePlural = { class Meme extends Object { id!: BSON.UUID; - memeType!: MEME_TYPE; filename!: string; + memeType!: MEME_TYPE; mimeType!: string; size!: number; title!: string; @@ -37,8 +37,8 @@ class Meme extends Object { primaryKey: 'id', properties: { id: { type: 'uuid', default: () => new BSON.UUID() }, - memeType: { type: 'string', indexed: true }, filename: 'string', + memeType: { type: 'string', indexed: true }, mimeType: 'string', size: 'int', title: 'string', diff --git a/src/screens/editors/addMeme.tsx b/src/screens/editors/addMeme.tsx index 6b15e56..87424e5 100644 --- a/src/screens/editors/addMeme.tsx +++ b/src/screens/editors/addMeme.tsx @@ -14,6 +14,7 @@ import { documentPickerResponseToAddMemeFile, ROUTE, RootStackParamList, + StagingMeme, } from '../../types'; import { Meme, Tag } from '../../database'; import { RootState } from '../../state'; @@ -43,92 +44,83 @@ const AddMeme = ({ const file = useRef(files.current[index]); const isLastFile = index === files.current.length - 1; - const [memeLoading, setMemeLoading] = useState(true); - const [memeError, setMemeError] = useState(); - const [isSaving, setIsSaving] = useState(false); const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false); - const [memeUri, setMemeUri] = useState(file.current.uri); - const [memeFilename, setMemeFilename] = useState(file.current.filename); - const [memeMimeType, setMemeMimeType] = useState(); - const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme')); - const [memeIsFavorite, setMemeIsFavorite] = useState(false); - const [memeTags, setMemeTags] = useState(new Map()); + const [uri, setUri] = useState(); + const [mimeType, setMimeType] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [staging, setStaging] = useState(); - const resetState = useCallback(async (newIndex = 0) => { - setMemeLoading(true); + const resetState = useCallback(async (newIndex: number) => { + setLoading(true); // eslint-disable-next-line unicorn/no-useless-undefined - setMemeError(undefined); + setError(undefined); setIndex(newIndex); file.current = files.current[newIndex]; - setMemeUri(file.current.uri); - setMemeFilename(file.current.filename); - setMemeTitle(validateMemeTitle('New Meme')); - setMemeIsFavorite(false); - setMemeTags(new Map()); + setUri(file.current.uri); - const mimeType = await guessMimeType(file.current.uri); - if (!mimeType) { - setMemeError( + const guessedMimeType = await guessMimeType(file.current.uri); + if (!guessedMimeType) { + setError( new Error('Could not determine MIME type or file is not supported.'), ); return; } - setMemeMimeType(mimeType); + setMimeType(guessedMimeType); - setMemeLoading(false); + setStaging({ + title: validateMemeTitle('New Meme'), + isFavorite: false, + tags: new Map(), + }); + + setLoading(false); }, []); - useEffect(() => void resetState(), [resetState]); + useEffect(() => void resetState(0), [resetState]); const saveMeme = useCallback(async () => { - if (!memeMimeType) return; + if (!mimeType || !staging) return; const uuid = new BSON.UUID(); - const memeType = getMemeTypeFromMimeType(memeMimeType); + const memeType = getMemeTypeFromMimeType(mimeType); if (!memeType) return; - const fileExtension = extension(memeMimeType); + const fileExtension = extension(mimeType); if (!fileExtension) return; const filename = `${uuid.toHexString()}-${Math.round( Date.now() / 1000, )}.${fileExtension}`; - const uri = AndroidScoped.appendPath(storageUri, filename); + const finalUri = AndroidScoped.appendPath(storageUri, filename); - await FileSystem.cp(file.current.uri, uri); - const { size } = await FileSystem.stat(uri); + await FileSystem.cp(file.current.uri, finalUri); + const { size } = await FileSystem.stat(finalUri); realm.write(() => { const meme: Meme | undefined = realm.create(Meme.schema.name, { id: uuid, memeType, filename, - mimeType: memeMimeType, + mimeType: mimeType, size, - title: memeTitle.parsed, - isFavorite: memeIsFavorite, - tags: [...memeTags.values()], - tagsLength: memeTags.size, + title: staging.title.parsed, + isFavorite: staging.isFavorite, + tags: [...staging.tags.values()], + tagsLength: staging.tags.size, }); - memeTags.forEach(tag => { + staging.tags.forEach(tag => { tag.dateModified = new Date(); tag.memes.push(meme); tag.memesLength = tag.memes.length; }); }); - }, [ - memeIsFavorite, - memeMimeType, - memeTags, - memeTitle.parsed, - realm, - storageUri, - ]); + }, [mimeType, realm, staging, storageUri]); const handleSave = useCallback(async () => { setIsSaving(true); @@ -162,20 +154,23 @@ const AddMeme = ({ goBack()} /> setMemeIsFavorite(!memeIsFavorite)} + icon={staging?.isFavorite ? 'heart' : 'heart-outline'} + disabled={!staging} + onPress={() => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setStaging({ ...staging!, isFavorite: !staging!.isFavorite }) + } /> - The selected URI appears to be broken. This may have been caused by the - file being corrupted or unsupported. + {error?.message} @@ -203,11 +198,11 @@ const AddMeme = ({ icon="plus" onPress={handleSaveAndAddMore} disabled={ - memeLoading || - !!memeError || + loading || + !!error || isSaving || isSavingAndAddingMore || - !memeTitle.valid || + !staging?.title.valid || !isLastFile } loading={isSavingAndAddingMore} @@ -219,11 +214,11 @@ const AddMeme = ({ icon="floppy" onPress={isLastFile ? handleSave : handleSaveAndNext} disabled={ - memeLoading || - !!memeError || + loading || + !!error || isSaving || isSavingAndAddingMore || - !memeTitle.valid + !staging?.title.valid } loading={isSaving} style={editorStyles.saveButton}> diff --git a/src/screens/editors/addTag.tsx b/src/screens/editors/addTag.tsx index 6f8c16b..5fdff72 100644 --- a/src/screens/editors/addTag.tsx +++ b/src/screens/editors/addTag.tsx @@ -12,6 +12,7 @@ import { import { Tag } from '../../database'; import { TagEditor } from '../../components'; import editorStyles from './editorStyles'; +import { StagingTag } from '../../types'; const AddTag = () => { const { goBack } = useNavigation(); @@ -19,10 +20,10 @@ const AddTag = () => { const orientation = useDeviceOrientation(); const realm = useRealm(); - const [tagName, setTagName] = useState(validateTagName('newTag')); - const [tagColor, setTagColor] = useState( - validateColor(generateRandomColor()), - ); + const [staging, setStaging] = useState({ + name: validateTagName('newTag'), + color: validateColor(generateRandomColor()), + }); // Although saving tags is instantaneous, we still want to show a loading // indicator to prevent the user from spamming the save button. @@ -31,11 +32,11 @@ const AddTag = () => { const saveTag = useCallback(() => { realm.write(() => { realm.create(Tag.schema.name, { - name: tagName.parsed, - color: tagColor.parsed, + name: staging.name.parsed, + color: staging.color.parsed, }); }); - }, [realm, tagColor.parsed, tagName.parsed]); + }, [realm, staging.color.parsed, staging.name.parsed]); const handleSave = useCallback(() => { saveTag(); @@ -46,8 +47,10 @@ const AddTag = () => { setIsSavingAndAddingMore(true); saveTag(); setTimeout(() => setIsSavingAndAddingMore(false), 250); - setTagName(validateTagName('newTag')); - setTagColor(validateColor(generateRandomColor())); + setStaging({ + name: validateTagName('newTag'), + color: validateColor(generateRandomColor()), + }); }, [saveTag]); return ( @@ -65,19 +68,14 @@ const AddTag = () => { { backgroundColor: colors.background }, ]}> - + diff --git a/src/screens/editors/editMeme.tsx b/src/screens/editors/editMeme.tsx index 2628b28..960410b 100644 --- a/src/screens/editors/editMeme.tsx +++ b/src/screens/editors/editMeme.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ScrollView, View } from 'react-native'; import { Appbar, Banner, Button, useTheme } from 'react-native-paper'; import { useNavigation } from '@react-navigation/native'; @@ -6,14 +6,13 @@ 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 { RootStackParamList, ROUTE, StagingMeme } 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 { Meme } from '../../database'; import { - StringValidationResult, allowedMimeTypes, deleteMeme, favoriteMeme, @@ -43,42 +42,67 @@ const EditMeme = ({ Meme.schema.name, BSON.UUID.createFromHexString(route.params.id), )!; - const uri = AndroidScoped.appendPath(storageUri, meme.filename); - - const [hasChanges, setHasChanges] = useState(false); - - 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])), - ); - - const handleMemeTitleChange = useCallback((title: StringValidationResult) => { - setMemeTitle(title); - setHasChanges(true); - }, []); - - const handleMemeTagsChange = useCallback((tags: Map) => { - setMemeTags(tags); - setHasChanges(true); - }, []); const [isSaving, setIsSaving] = useState(false); + const [uri, setUri] = useState(); + const [mimeType, setMimeType] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [staging, setStaging] = useState(); + const originalStaging = useRef(); + + const resetState = useCallback( + async (newUri: string) => { + setLoading(true); + // eslint-disable-next-line unicorn/no-useless-undefined + setError(undefined); + + setUri(newUri); + + const guessedMimeType = await guessMimeType(newUri); + if (!guessedMimeType) { + setError( + new Error('Could not determine MIME type or file is not supported.'), + ); + return; + } + setMimeType(guessedMimeType); + + const stagingMeme = { + title: validateMemeTitle(meme.title), + isFavorite: meme.isFavorite, + tags: new Map(meme.tags.map(tag => [tag.id.toHexString(), tag])), + }; + + setStaging(stagingMeme); + originalStaging.current = stagingMeme; + + setLoading(false); + }, + [meme.isFavorite, meme.tags, meme.title], + ); + + useEffect( + () => void resetState(AndroidScoped.appendPath(storageUri, meme.filename)), + [meme.filename, resetState, storageUri], + ); + const handleSave = useCallback(() => { + if (!mimeType || !staging) return; + setIsSaving(true); realm.write(() => { meme.tags.forEach(tag => { - if (!memeTags.has(tag.id.toHexString())) { + if (!staging.tags.has(tag.id.toHexString())) { tag.memes.slice(tag.memes.indexOf(meme), 1); tag.memesLength -= 1; tag.dateModified = new Date(); } }); - memeTags.forEach(tag => { + staging.tags.forEach(tag => { if (!meme.tags.some(memeTag => memeTag.id.equals(tag.id))) { tag.memes.push(meme); tag.memesLength = tag.memes.length; @@ -86,15 +110,15 @@ const EditMeme = ({ } }); - meme.title = memeTitle.parsed; + meme.title = staging.title.parsed; // @ts-expect-error - Realm is a fuck - meme.tags = [...memeTags.values()]; - meme.tagsLength = memeTags.size; + meme.tags = [...staging.tags.values()]; + meme.tagsLength = staging.tags.size; meme.dateModified = new Date(); }); goBack(); - }, [goBack, meme, memeTags, memeTitle.parsed, realm]); + }, [goBack, meme, mimeType, realm, staging]); const handleDelete = useCallback(async () => { setIsSaving(true); @@ -106,18 +130,18 @@ const EditMeme = ({ const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp); if (!file) return; - const mimeType = await guessMimeType(file.uri, file.type); - if (!mimeType) { - setMemeError( + const guessedMimeType = await guessMimeType(file.uri, file.type); + if (!guessedMimeType) { + setError( new Error('Could not determine MIME type or file is not supported.'), ); return; } - const memeType = getMemeTypeFromMimeType(mimeType); + const memeType = getMemeTypeFromMimeType(guessedMimeType); if (!memeType) return; - const fileExtension = extension(mimeType) as string; + const fileExtension = extension(guessedMimeType); if (!fileExtension) return; const filename = `${meme.id.toHexString()}-${ @@ -131,10 +155,12 @@ const EditMeme = ({ realm.write(() => { meme.filename = filename; meme.memeType = memeType; - meme.mimeType = mimeType; + meme.mimeType = guessedMimeType; meme.size = size; }); - }, [meme, realm, storageUri]); + + void resetState(newUri); + }, [meme, realm, resetState, storageUri]); return ( <> @@ -148,7 +174,7 @@ const EditMeme = ({ - The URI for this meme appears to be broken. This may have been caused by - the file being moved or deleted. + {error?.message} @@ -188,7 +213,11 @@ const EditMeme = ({ icon="floppy" onPress={handleSave} disabled={ - !memeTitle.valid || !hasChanges || isSaving || !!memeError + loading || + !!error || + isSaving || + !staging?.title.valid || + originalStaging.current === staging } loading={isSaving} style={editorStyles.soloSaveButton}> diff --git a/src/screens/editors/editTag.tsx b/src/screens/editors/editTag.tsx index b9a087c..58f8a1a 100644 --- a/src/screens/editors/editTag.tsx +++ b/src/screens/editors/editTag.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { ScrollView, View } from 'react-native'; import { Appbar, Button, useTheme } from 'react-native-paper'; import { useNavigation } from '@react-navigation/native'; @@ -7,14 +7,9 @@ import { BSON } from 'realm'; import { useObject, useRealm } from '@realm/react'; import { useDeviceOrientation } from '@react-native-community/hooks'; import { TagEditor } from '../../components'; -import { ROUTE, RootStackParamList } from '../../types'; +import { ROUTE, RootStackParamList, StagingTag } from '../../types'; import { Tag } from '../../database'; -import { - StringValidationResult, - deleteTag, - validateColor, - validateTagName, -} from '../../utilities'; +import { deleteTag, validateColor, validateTagName } from '../../utilities'; import editorStyles from './editorStyles'; const EditTag = ({ @@ -31,29 +26,21 @@ 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 [staging, setStaging] = useState({ + name: validateTagName(tag.name), + color: validateColor(tag.color), + }); + const originalStaging = useRef(staging); const handleSave = useCallback(() => { realm.write(() => { - tag.name = tagName.parsed; - tag.color = tagColor.parsed; + tag.name = staging.name.parsed; + tag.color = staging.color.parsed; tag.dateModified = new Date(); }); goBack(); - }, [goBack, realm, tag, tagColor.parsed, tagName.parsed]); + }, [goBack, realm, staging.color.parsed, staging.name.parsed, tag]); return ( <> @@ -78,19 +65,18 @@ const EditTag = ({ ]} nestedScrollEnabled> - + diff --git a/src/types/index.ts b/src/types/index.ts index a7cbce0..0db855d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,4 +13,5 @@ export { tagSortQuery, SORT_DIRECTION, } from './sort'; +export { type StagingMeme, type StagingTag } from './staging'; export { VIEW } from './view'; diff --git a/src/types/staging.ts b/src/types/staging.ts new file mode 100644 index 0000000..975f005 --- /dev/null +++ b/src/types/staging.ts @@ -0,0 +1,15 @@ +import { Tag } from '../database'; +import { StringValidationResult } from '../utilities'; + +interface StagingMeme { + title: StringValidationResult; + isFavorite: boolean; + tags: Map; +} + +interface StagingTag { + name: StringValidationResult; + color: StringValidationResult; +} + +export { type StagingMeme, type StagingTag }; diff --git a/src/utilities/filesystem.ts b/src/utilities/filesystem.ts index 24e2937..1c06270 100644 --- a/src/utilities/filesystem.ts +++ b/src/utilities/filesystem.ts @@ -64,10 +64,7 @@ const guessMimeType = async ( uri: string, hint?: string | null, ): Promise => { - if (hint) { - if (allowedMimeTypes.includes(hint)) return hint; - if (!hint.startsWith('image/')) return undefined; - } + if (hint && allowedMimeTypes.includes(hint)) return hint; const guessedMimeType = guessMimeTypeFromExtension(uri); if (guessedMimeType) return guessedMimeType;