Refactor editor architecture

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-08-01 08:58:35 +03:00
parent 880c20661e
commit f1f969c8ea
10 changed files with 265 additions and 224 deletions

View File

@@ -1,11 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper'; 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 { useSafeAreaFrame } from 'react-native-safe-area-context';
import { MemeFail, MemeTagSelector } from '..'; import { LoadingView, MemeFail, MemeTagSelector } from '..';
import { Tag } from '../../database'; import { getFilenameFromUri, validateMemeTitle } from '../../utilities';
import { StringValidationResult, validateMemeTitle } from '../../utilities'; import { Dimensions, StagingMeme } from '../../types';
import { Dimensions } from '../../types';
const memeEditorStyles = { const memeEditorStyles = {
image: { image: {
@@ -25,43 +24,46 @@ const memeEditorStyles = {
}; };
const MemeEditor = ({ const MemeEditor = ({
memeUri, uri,
memeFilename, mimeType,
memeError, setLoading,
setMemeError, error,
memeTitle, setError,
setMemeTitle, staging,
memeTags, setStaging,
setMemeTags,
}: { }: {
memeUri: string; uri?: string;
memeFilename?: string; mimeType?: string;
memeError: Error | undefined; loading: boolean;
setMemeError: (error: Error | undefined) => void; setLoading: (loading: boolean) => void;
memeTitle: StringValidationResult; error: Error | undefined;
setMemeTitle: (name: StringValidationResult) => void; setError: (error: Error | undefined) => void;
memeTags: Map<string, Tag>; staging?: StagingMeme;
setMemeTags: (tags: Map<string, Tag>) => void; setStaging: (staging: StagingMeme) => void;
}) => { }) => {
const { width } = useSafeAreaFrame(); const { width } = useSafeAreaFrame();
const { colors } = useTheme(); const { colors } = useTheme();
const [dimensions, setDimensions] = useState<Dimensions>(); const [dimensions, setDimensions] = useState<Dimensions>();
if (!uri || !mimeType || !staging) return <LoadingView />;
return ( return (
<> <>
<TextInput <TextInput
mode="outlined" mode="outlined"
label="Title" label="Title"
value={memeTitle.raw} value={staging.title.raw}
onChangeText={title => setMemeTitle(validateMemeTitle(title))} onChangeText={title =>
error={!memeTitle.valid} setStaging({ ...staging, title: validateMemeTitle(title) })
}
error={!staging.title.valid}
selectTextOnFocus selectTextOnFocus
/> />
<HelperText type="error" visible={!memeTitle.valid}> <HelperText type="error" visible={!staging.title.valid}>
{memeTitle.error} {staging.title.error}
</HelperText> </HelperText>
{memeError ? ( {error ? (
<MemeFail <MemeFail
style={[ style={[
{ {
@@ -74,7 +76,7 @@ const MemeEditor = ({
/> />
) : ( ) : (
<Image <Image
source={{ uri: memeUri }} source={{ uri }}
style={[ style={[
dimensions dimensions
? { ? {
@@ -87,9 +89,10 @@ const MemeEditor = ({
100, 100,
), ),
} }
: { : // eslint-disable-next-line react-native/no-inline-styles
{
width: width * 0.92, width: width * 0.92,
height: width * 0.92, height: 1,
}, },
memeEditorStyles.image, memeEditorStyles.image,
]} ]}
@@ -99,19 +102,29 @@ const MemeEditor = ({
width: event.nativeEvent.source.width, width: event.nativeEvent.source.width,
height: event.nativeEvent.source.height, height: event.nativeEvent.source.height,
}); });
setLoading(false);
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
);
}} }}
onError={event => 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.',
),
)
}
/> />
)} )}
<Text <Text
variant="bodySmall" variant="bodySmall"
style={[memeEditorStyles.uri, { color: colors.onSurfaceDisabled }]} style={[memeEditorStyles.uri, { color: colors.onSurfaceDisabled }]}
numberOfLines={1}> numberOfLines={1}>
{memeFilename} {getFilenameFromUri(uri)}
</Text> </Text>
<MemeTagSelector <MemeTagSelector
memeTags={memeTags} memeTags={staging.tags}
setMemeTags={setMemeTags} setMemeTags={tags => setStaging({ ...staging, tags })}
style={memeEditorStyles.memeTagSelector} style={memeEditorStyles.memeTagSelector}
/> />
</> </>

View File

@@ -2,62 +2,69 @@ import React, { useEffect, useRef } from 'react';
import { HelperText, TextInput } from 'react-native-paper'; import { HelperText, TextInput } from 'react-native-paper';
import TagPreview from './tagPreview'; import TagPreview from './tagPreview';
import { import {
StringValidationResult,
generateRandomColor, generateRandomColor,
validateColor, validateColor,
validateTagName, validateTagName,
} from '../../utilities'; } from '../../utilities';
import { StagingTag } from '../../types';
const TagEditor = ({ const TagEditor = ({
tagName, staging,
setTagName, setStaging,
tagColor,
setTagColor,
}: { }: {
tagName: StringValidationResult; staging: StagingTag;
setTagName: (name: StringValidationResult) => void; setStaging: (staging: StagingTag) => void;
tagColor: StringValidationResult;
setTagColor: (color: StringValidationResult) => void;
}) => { }) => {
const lastValidTagColor = useRef(tagColor.parsed); const lastValidColor = useRef(staging.color.parsed);
useEffect(() => { useEffect(() => {
if (tagColor.valid) lastValidTagColor.current = tagColor.parsed; if (staging.color.valid) lastValidColor.current = staging.color.parsed;
}, [tagColor]); }, [staging.color.parsed, staging.color.valid]);
return ( return (
<> <>
<TagPreview <TagPreview
name={tagName.parsed} name={staging.name.parsed}
color={tagColor.valid ? tagColor.parsed : lastValidTagColor.current} color={
staging.color.valid ? staging.color.parsed : lastValidColor.current
}
/> />
<TextInput <TextInput
mode="outlined" mode="outlined"
label="Name" label="Name"
value={tagName.raw} value={staging.name.raw}
onChangeText={name => setTagName(validateTagName(name))} onChangeText={name =>
error={!tagName.valid} setStaging({ ...staging, name: validateTagName(name) })
}
error={!staging.name.valid}
selectTextOnFocus selectTextOnFocus
/> />
<HelperText type="error" visible={!tagName.valid}> <HelperText type="error" visible={!staging.name.valid}>
{tagName.error} {staging.name.error}
</HelperText> </HelperText>
<TextInput <TextInput
mode="outlined" mode="outlined"
label="Color" label="Color"
value={tagColor.raw} value={staging.color.raw}
onChangeText={color => setTagColor(validateColor(color))} onChangeText={color =>
error={!tagColor.valid} setStaging({ ...staging, color: validateColor(color) })
}
error={!staging.color.valid}
autoCorrect={false} autoCorrect={false}
right={ right={
<TextInput.Icon <TextInput.Icon
icon="palette" icon="palette"
onPress={() => setTagColor(validateColor(generateRandomColor()))} onPress={() =>
setStaging({
...staging,
color: validateColor(generateRandomColor()),
})
}
/> />
} }
/> />
<HelperText type="error" visible={!tagColor.valid}> <HelperText type="error" visible={!staging.color.valid}>
{tagColor.error} {staging.color.error}
</HelperText> </HelperText>
</> </>
); );

View File

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

View File

@@ -14,6 +14,7 @@ import {
documentPickerResponseToAddMemeFile, documentPickerResponseToAddMemeFile,
ROUTE, ROUTE,
RootStackParamList, RootStackParamList,
StagingMeme,
} from '../../types'; } from '../../types';
import { Meme, Tag } from '../../database'; import { Meme, Tag } from '../../database';
import { RootState } from '../../state'; import { RootState } from '../../state';
@@ -43,92 +44,83 @@ const AddMeme = ({
const file = useRef(files.current[index]); const file = useRef(files.current[index]);
const isLastFile = index === files.current.length - 1; const isLastFile = index === files.current.length - 1;
const [memeLoading, setMemeLoading] = useState(true);
const [memeError, setMemeError] = useState<Error>();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false); const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false);
const [memeUri, setMemeUri] = useState(file.current.uri); const [uri, setUri] = useState<string>();
const [memeFilename, setMemeFilename] = useState(file.current.filename); const [mimeType, setMimeType] = useState<string>();
const [memeMimeType, setMemeMimeType] = useState<string>(); const [loading, setLoading] = useState(true);
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme')); const [error, setError] = useState<Error>();
const [memeIsFavorite, setMemeIsFavorite] = useState(false); const [staging, setStaging] = useState<StagingMeme>();
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
const resetState = useCallback(async (newIndex = 0) => { const resetState = useCallback(async (newIndex: number) => {
setMemeLoading(true); setLoading(true);
// eslint-disable-next-line unicorn/no-useless-undefined // eslint-disable-next-line unicorn/no-useless-undefined
setMemeError(undefined); setError(undefined);
setIndex(newIndex); setIndex(newIndex);
file.current = files.current[newIndex]; file.current = files.current[newIndex];
setMemeUri(file.current.uri); setUri(file.current.uri);
setMemeFilename(file.current.filename);
setMemeTitle(validateMemeTitle('New Meme'));
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
const mimeType = await guessMimeType(file.current.uri); const guessedMimeType = await guessMimeType(file.current.uri);
if (!mimeType) { if (!guessedMimeType) {
setMemeError( setError(
new Error('Could not determine MIME type or file is not supported.'), new Error('Could not determine MIME type or file is not supported.'),
); );
return; return;
} }
setMemeMimeType(mimeType); setMimeType(guessedMimeType);
setMemeLoading(false); setStaging({
title: validateMemeTitle('New Meme'),
isFavorite: false,
tags: new Map<string, Tag>(),
});
setLoading(false);
}, []); }, []);
useEffect(() => void resetState(), [resetState]); useEffect(() => void resetState(0), [resetState]);
const saveMeme = useCallback(async () => { const saveMeme = useCallback(async () => {
if (!memeMimeType) return; if (!mimeType || !staging) return;
const uuid = new BSON.UUID(); const uuid = new BSON.UUID();
const memeType = getMemeTypeFromMimeType(memeMimeType); const memeType = getMemeTypeFromMimeType(mimeType);
if (!memeType) return; if (!memeType) return;
const fileExtension = extension(memeMimeType); const fileExtension = extension(mimeType);
if (!fileExtension) return; if (!fileExtension) return;
const filename = `${uuid.toHexString()}-${Math.round( const filename = `${uuid.toHexString()}-${Math.round(
Date.now() / 1000, Date.now() / 1000,
)}.${fileExtension}`; )}.${fileExtension}`;
const uri = AndroidScoped.appendPath(storageUri, filename); const finalUri = AndroidScoped.appendPath(storageUri, filename);
await FileSystem.cp(file.current.uri, uri); await FileSystem.cp(file.current.uri, finalUri);
const { size } = await FileSystem.stat(uri); const { size } = await FileSystem.stat(finalUri);
realm.write(() => { realm.write(() => {
const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, { const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, {
id: uuid, id: uuid,
memeType, memeType,
filename, filename,
mimeType: memeMimeType, mimeType: mimeType,
size, size,
title: memeTitle.parsed, title: staging.title.parsed,
isFavorite: memeIsFavorite, isFavorite: staging.isFavorite,
tags: [...memeTags.values()], tags: [...staging.tags.values()],
tagsLength: memeTags.size, tagsLength: staging.tags.size,
}); });
memeTags.forEach(tag => { staging.tags.forEach(tag => {
tag.dateModified = new Date(); tag.dateModified = new Date();
tag.memes.push(meme); tag.memes.push(meme);
tag.memesLength = tag.memes.length; tag.memesLength = tag.memes.length;
}); });
}); });
}, [ }, [mimeType, realm, staging, storageUri]);
memeIsFavorite,
memeMimeType,
memeTags,
memeTitle.parsed,
realm,
storageUri,
]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@@ -162,20 +154,23 @@ const AddMeme = ({
<Appbar.BackAction onPress={() => goBack()} /> <Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Add Meme'} /> <Appbar.Content title={'Add Meme'} />
<Appbar.Action <Appbar.Action
icon={memeIsFavorite ? 'heart' : 'heart-outline'} icon={staging?.isFavorite ? 'heart' : 'heart-outline'}
onPress={() => setMemeIsFavorite(!memeIsFavorite)} disabled={!staging}
onPress={() =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setStaging({ ...staging!, isFavorite: !staging!.isFavorite })
}
/> />
</Appbar.Header> </Appbar.Header>
<Banner <Banner
visible={!!memeError} visible={!!error}
actions={[ actions={[
{ {
label: 'Cancel', label: 'Cancel',
onPress: goBack, onPress: goBack,
}, },
]}> ]}>
The selected URI appears to be broken. This may have been caused by the {error?.message}
file being corrupted or unsupported.
</Banner> </Banner>
<ScrollView <ScrollView
contentContainerStyle={[ contentContainerStyle={[
@@ -187,14 +182,14 @@ const AddMeme = ({
]}> ]}>
<View style={editorStyles.editorView}> <View style={editorStyles.editorView}>
<MemeEditor <MemeEditor
memeUri={memeUri} uri={uri}
memeFilename={memeFilename} mimeType={mimeType}
memeError={memeError} loading={loading}
setMemeError={setMemeError} setLoading={setLoading}
memeTitle={memeTitle} error={error}
setMemeTitle={setMemeTitle} setError={setError}
memeTags={memeTags} staging={staging}
setMemeTags={setMemeTags} setStaging={setStaging}
/> />
</View> </View>
<View style={editorStyles.saveButtonView}> <View style={editorStyles.saveButtonView}>
@@ -203,11 +198,11 @@ const AddMeme = ({
icon="plus" icon="plus"
onPress={handleSaveAndAddMore} onPress={handleSaveAndAddMore}
disabled={ disabled={
memeLoading || loading ||
!!memeError || !!error ||
isSaving || isSaving ||
isSavingAndAddingMore || isSavingAndAddingMore ||
!memeTitle.valid || !staging?.title.valid ||
!isLastFile !isLastFile
} }
loading={isSavingAndAddingMore} loading={isSavingAndAddingMore}
@@ -219,11 +214,11 @@ const AddMeme = ({
icon="floppy" icon="floppy"
onPress={isLastFile ? handleSave : handleSaveAndNext} onPress={isLastFile ? handleSave : handleSaveAndNext}
disabled={ disabled={
memeLoading || loading ||
!!memeError || !!error ||
isSaving || isSaving ||
isSavingAndAddingMore || isSavingAndAddingMore ||
!memeTitle.valid !staging?.title.valid
} }
loading={isSaving} loading={isSaving}
style={editorStyles.saveButton}> style={editorStyles.saveButton}>

View File

@@ -12,6 +12,7 @@ import {
import { Tag } from '../../database'; import { Tag } from '../../database';
import { TagEditor } from '../../components'; import { TagEditor } from '../../components';
import editorStyles from './editorStyles'; import editorStyles from './editorStyles';
import { StagingTag } from '../../types';
const AddTag = () => { const AddTag = () => {
const { goBack } = useNavigation(); const { goBack } = useNavigation();
@@ -19,10 +20,10 @@ const AddTag = () => {
const orientation = useDeviceOrientation(); const orientation = useDeviceOrientation();
const realm = useRealm(); const realm = useRealm();
const [tagName, setTagName] = useState(validateTagName('newTag')); const [staging, setStaging] = useState<StagingTag>({
const [tagColor, setTagColor] = useState( name: validateTagName('newTag'),
validateColor(generateRandomColor()), color: validateColor(generateRandomColor()),
); });
// Although saving tags is instantaneous, we still want to show a loading // Although saving tags is instantaneous, we still want to show a loading
// indicator to prevent the user from spamming the save button. // indicator to prevent the user from spamming the save button.
@@ -31,11 +32,11 @@ const AddTag = () => {
const saveTag = useCallback(() => { const saveTag = useCallback(() => {
realm.write(() => { realm.write(() => {
realm.create(Tag.schema.name, { realm.create(Tag.schema.name, {
name: tagName.parsed, name: staging.name.parsed,
color: tagColor.parsed, color: staging.color.parsed,
}); });
}); });
}, [realm, tagColor.parsed, tagName.parsed]); }, [realm, staging.color.parsed, staging.name.parsed]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
saveTag(); saveTag();
@@ -46,8 +47,10 @@ const AddTag = () => {
setIsSavingAndAddingMore(true); setIsSavingAndAddingMore(true);
saveTag(); saveTag();
setTimeout(() => setIsSavingAndAddingMore(false), 250); setTimeout(() => setIsSavingAndAddingMore(false), 250);
setTagName(validateTagName('newTag')); setStaging({
setTagColor(validateColor(generateRandomColor())); name: validateTagName('newTag'),
color: validateColor(generateRandomColor()),
});
}, [saveTag]); }, [saveTag]);
return ( return (
@@ -65,19 +68,14 @@ const AddTag = () => {
{ backgroundColor: colors.background }, { backgroundColor: colors.background },
]}> ]}>
<View style={editorStyles.editorView}> <View style={editorStyles.editorView}>
<TagEditor <TagEditor staging={staging} setStaging={setStaging} />
tagName={tagName}
setTagName={setTagName}
tagColor={tagColor}
setTagColor={setTagColor}
/>
</View> </View>
<View style={editorStyles.saveButtonView}> <View style={editorStyles.saveButtonView}>
<Button <Button
mode="contained-tonal" mode="contained-tonal"
icon="plus" icon="plus"
onPress={handleSaveAndAddMore} onPress={handleSaveAndAddMore}
disabled={!tagName.valid || isSavingAndAddingMore} disabled={!staging.name.valid || isSavingAndAddingMore}
loading={isSavingAndAddingMore} loading={isSavingAndAddingMore}
style={editorStyles.saveAndAddButton}> style={editorStyles.saveAndAddButton}>
Save & Add Save & Add
@@ -86,7 +84,7 @@ const AddTag = () => {
mode="contained" mode="contained"
icon="floppy" icon="floppy"
onPress={handleSave} onPress={handleSave}
disabled={!tagName.valid || isSavingAndAddingMore} disabled={!staging.name.valid || isSavingAndAddingMore}
style={editorStyles.saveButton}> style={editorStyles.saveButton}>
Save Save
</Button> </Button>

View File

@@ -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 { ScrollView, View } from 'react-native';
import { Appbar, Banner, Button, useTheme } from 'react-native-paper'; import { Appbar, Banner, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -6,14 +6,13 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useObject, useRealm } from '@realm/react'; import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks'; import { useDeviceOrientation } from '@react-native-community/hooks';
import { BSON } from 'realm'; import { BSON } from 'realm';
import { RootStackParamList, ROUTE } from '../../types'; import { RootStackParamList, ROUTE, StagingMeme } from '../../types';
import { pickSingle } from 'react-native-document-picker'; import { pickSingle } from 'react-native-document-picker';
import { AndroidScoped, FileSystem } from 'react-native-file-access'; import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { extension } from 'react-native-mime-types'; import { extension } from 'react-native-mime-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Tag, Meme } from '../../database'; import { Meme } from '../../database';
import { import {
StringValidationResult,
allowedMimeTypes, allowedMimeTypes,
deleteMeme, deleteMeme,
favoriteMeme, favoriteMeme,
@@ -43,42 +42,67 @@ const EditMeme = ({
Meme.schema.name, Meme.schema.name,
BSON.UUID.createFromHexString(route.params.id), BSON.UUID.createFromHexString(route.params.id),
)!; )!;
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const [hasChanges, setHasChanges] = useState(false);
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])),
);
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 [isSaving, setIsSaving] = useState(false);
const [uri, setUri] = useState<string>();
const [mimeType, setMimeType] = useState<string>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error>();
const [staging, setStaging] = useState<StagingMeme>();
const originalStaging = useRef<StagingMeme>();
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(() => { const handleSave = useCallback(() => {
if (!mimeType || !staging) return;
setIsSaving(true); setIsSaving(true);
realm.write(() => { realm.write(() => {
meme.tags.forEach(tag => { 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.memes.slice(tag.memes.indexOf(meme), 1);
tag.memesLength -= 1; tag.memesLength -= 1;
tag.dateModified = new Date(); tag.dateModified = new Date();
} }
}); });
memeTags.forEach(tag => { staging.tags.forEach(tag => {
if (!meme.tags.some(memeTag => memeTag.id.equals(tag.id))) { if (!meme.tags.some(memeTag => memeTag.id.equals(tag.id))) {
tag.memes.push(meme); tag.memes.push(meme);
tag.memesLength = tag.memes.length; 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 // @ts-expect-error - Realm is a fuck
meme.tags = [...memeTags.values()]; meme.tags = [...staging.tags.values()];
meme.tagsLength = memeTags.size; meme.tagsLength = staging.tags.size;
meme.dateModified = new Date(); meme.dateModified = new Date();
}); });
goBack(); goBack();
}, [goBack, meme, memeTags, memeTitle.parsed, realm]); }, [goBack, meme, mimeType, realm, staging]);
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@@ -106,18 +130,18 @@ const EditMeme = ({
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp); const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
if (!file) return; if (!file) return;
const mimeType = await guessMimeType(file.uri, file.type); const guessedMimeType = await guessMimeType(file.uri, file.type);
if (!mimeType) { if (!guessedMimeType) {
setMemeError( setError(
new Error('Could not determine MIME type or file is not supported.'), new Error('Could not determine MIME type or file is not supported.'),
); );
return; return;
} }
const memeType = getMemeTypeFromMimeType(mimeType); const memeType = getMemeTypeFromMimeType(guessedMimeType);
if (!memeType) return; if (!memeType) return;
const fileExtension = extension(mimeType) as string; const fileExtension = extension(guessedMimeType);
if (!fileExtension) return; if (!fileExtension) return;
const filename = `${meme.id.toHexString()}-${ const filename = `${meme.id.toHexString()}-${
@@ -131,10 +155,12 @@ const EditMeme = ({
realm.write(() => { realm.write(() => {
meme.filename = filename; meme.filename = filename;
meme.memeType = memeType; meme.memeType = memeType;
meme.mimeType = mimeType; meme.mimeType = guessedMimeType;
meme.size = size; meme.size = size;
}); });
}, [meme, realm, storageUri]);
void resetState(newUri);
}, [meme, realm, resetState, storageUri]);
return ( return (
<> <>
@@ -148,7 +174,7 @@ const EditMeme = ({
<Appbar.Action icon="delete" onPress={handleDelete} /> <Appbar.Action icon="delete" onPress={handleDelete} />
</Appbar.Header> </Appbar.Header>
<Banner <Banner
visible={!!memeError} visible={!!error}
actions={[ actions={[
{ {
label: 'Fix URI', label: 'Fix URI',
@@ -159,8 +185,7 @@ const EditMeme = ({
onPress: handleDelete, onPress: handleDelete,
}, },
]}> ]}>
The URI for this meme appears to be broken. This may have been caused by {error?.message}
the file being moved or deleted.
</Banner> </Banner>
<ScrollView <ScrollView
contentContainerStyle={[ contentContainerStyle={[
@@ -172,14 +197,14 @@ const EditMeme = ({
]}> ]}>
<View style={editorStyles.editorView}> <View style={editorStyles.editorView}>
<MemeEditor <MemeEditor
memeUri={uri} uri={uri}
memeFilename={meme.filename} mimeType={mimeType}
memeError={memeError} loading={loading}
setMemeError={setMemeError} setLoading={setLoading}
memeTitle={memeTitle} error={error}
setMemeTitle={handleMemeTitleChange} setError={setError}
memeTags={memeTags} staging={staging}
setMemeTags={handleMemeTagsChange} setStaging={setStaging}
/> />
</View> </View>
<View style={editorStyles.saveButtonView}> <View style={editorStyles.saveButtonView}>
@@ -188,7 +213,11 @@ const EditMeme = ({
icon="floppy" icon="floppy"
onPress={handleSave} onPress={handleSave}
disabled={ disabled={
!memeTitle.valid || !hasChanges || isSaving || !!memeError loading ||
!!error ||
isSaving ||
!staging?.title.valid ||
originalStaging.current === staging
} }
loading={isSaving} loading={isSaving}
style={editorStyles.soloSaveButton}> style={editorStyles.soloSaveButton}>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { ScrollView, View } from 'react-native'; import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper'; import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -7,14 +7,9 @@ import { BSON } from 'realm';
import { useObject, useRealm } from '@realm/react'; import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks'; import { useDeviceOrientation } from '@react-native-community/hooks';
import { TagEditor } from '../../components'; import { TagEditor } from '../../components';
import { ROUTE, RootStackParamList } from '../../types'; import { ROUTE, RootStackParamList, StagingTag } from '../../types';
import { Tag } from '../../database'; import { Tag } from '../../database';
import { import { deleteTag, validateColor, validateTagName } from '../../utilities';
StringValidationResult,
deleteTag,
validateColor,
validateTagName,
} from '../../utilities';
import editorStyles from './editorStyles'; import editorStyles from './editorStyles';
const EditTag = ({ const EditTag = ({
@@ -31,29 +26,21 @@ const EditTag = ({
BSON.UUID.createFromHexString(route.params.id), BSON.UUID.createFromHexString(route.params.id),
)!; )!;
const [hasChanges, setHasChanges] = useState(false); const [staging, setStaging] = useState<StagingTag>({
const [tagName, setTagName] = useState(validateTagName(tag.name)); name: validateTagName(tag.name),
const [tagColor, setTagColor] = useState(validateColor(tag.color)); color: validateColor(tag.color),
});
const handleTagNameChange = useCallback((name: StringValidationResult) => { const originalStaging = useRef<StagingTag>(staging);
setTagName(name);
setHasChanges(true);
}, []);
const handleTagColorChange = useCallback((color: StringValidationResult) => {
setTagColor(color);
setHasChanges(true);
}, []);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
realm.write(() => { realm.write(() => {
tag.name = tagName.parsed; tag.name = staging.name.parsed;
tag.color = tagColor.parsed; tag.color = staging.color.parsed;
tag.dateModified = new Date(); tag.dateModified = new Date();
}); });
goBack(); goBack();
}, [goBack, realm, tag, tagColor.parsed, tagName.parsed]); }, [goBack, realm, staging.color.parsed, staging.name.parsed, tag]);
return ( return (
<> <>
@@ -78,19 +65,18 @@ const EditTag = ({
]} ]}
nestedScrollEnabled> nestedScrollEnabled>
<View style={editorStyles.editorView}> <View style={editorStyles.editorView}>
<TagEditor <TagEditor staging={staging} setStaging={setStaging} />
tagName={tagName}
setTagName={handleTagNameChange}
tagColor={tagColor}
setTagColor={handleTagColorChange}
/>
</View> </View>
<View style={editorStyles.saveButtonView}> <View style={editorStyles.saveButtonView}>
<Button <Button
mode="contained" mode="contained"
icon="floppy" icon="floppy"
onPress={handleSave} onPress={handleSave}
disabled={!tagName.valid || !tagColor.valid || !hasChanges} disabled={
!staging.name.valid ||
!staging.color.valid ||
originalStaging.current === staging
}
style={editorStyles.soloSaveButton}> style={editorStyles.soloSaveButton}>
Save Save
</Button> </Button>

View File

@@ -13,4 +13,5 @@ export {
tagSortQuery, tagSortQuery,
SORT_DIRECTION, SORT_DIRECTION,
} from './sort'; } from './sort';
export { type StagingMeme, type StagingTag } from './staging';
export { VIEW } from './view'; export { VIEW } from './view';

15
src/types/staging.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Tag } from '../database';
import { StringValidationResult } from '../utilities';
interface StagingMeme {
title: StringValidationResult;
isFavorite: boolean;
tags: Map<string, Tag>;
}
interface StagingTag {
name: StringValidationResult;
color: StringValidationResult;
}
export { type StagingMeme, type StagingTag };

View File

@@ -64,10 +64,7 @@ const guessMimeType = async (
uri: string, uri: string,
hint?: string | null, hint?: string | null,
): Promise<string | undefined> => { ): Promise<string | undefined> => {
if (hint) { if (hint && allowedMimeTypes.includes(hint)) return hint;
if (allowedMimeTypes.includes(hint)) return hint;
if (!hint.startsWith('image/')) return undefined;
}
const guessedMimeType = guessMimeTypeFromExtension(uri); const guessedMimeType = guessMimeTypeFromExtension(uri);
if (guessedMimeType) return guessedMimeType; if (guessedMimeType) return guessedMimeType;