Refactor editor architecture
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -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',
|
||||||
|
@@ -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}>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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}>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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
15
src/types/staging.ts
Normal 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 };
|
@@ -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;
|
||||||
|
Reference in New Issue
Block a user