Add meme view & sharing

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-24 21:55:36 +03:00
parent 04661ca356
commit e479e3c0ad
33 changed files with 724 additions and 482 deletions

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { ORIENTATION, useDimensions } from '../contexts';
import { ScrollView, View } from 'react-native';
import { ScrollView, StyleSheet, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useRealm } from '@realm/react';
import { BSON } from 'realm';
@@ -13,8 +13,23 @@ import styles from '../styles';
import { ROUTE, RootStackParamList } from '../types';
import { Meme, Tag } from '../database';
import { RootState } from '../state';
import { getMemeType, validateMemeTitle } from '../utilities';
import { allowedMimeTypes, getMemeType, validateMemeTitle } from '../utilities';
import { MemeEditor } from '../components';
import {
DocumentPickerResponse,
pickSingle,
} from 'react-native-document-picker';
const addMemeStyles = StyleSheet.create({
saveAndAddButton: {
flex: 1,
marginRight: 5,
},
saveButton: {
flex: 1,
marginLeft: 5,
},
});
const AddMeme = ({
route,
@@ -28,31 +43,32 @@ const AddMeme = ({
(state: RootState) => state.settings.storageUri,
)!;
const { file } = route.params;
const file = useRef(route.params.file);
const [memeUri, setMemeUri] = useState(file.current.uri);
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const handleSave = async () => {
setIsSaving(true);
const handleSave = useCallback(async () => {
const uuid = new BSON.UUID();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mimeType = file.type!;
const mimeType = file.current.type!;
const memeType = getMemeType(mimeType);
const fileExtension = extension(mimeType);
const fileExtension = extension(mimeType) as string;
if (!fileExtension) goBack();
const uri = AndroidScoped.appendPath(
storageUri,
`${uuid.toHexString()}.${fileExtension as string}`,
`${uuid.toHexString()}.${fileExtension}`,
);
await FileSystem.cp(file.uri, uri);
await FileSystem.cp(file.current.uri, uri);
const { size } = await FileSystem.stat(uri);
realm.write(() => {
@@ -60,6 +76,7 @@ const AddMeme = ({
id: uuid,
type: memeType,
uri,
mimeType,
size,
title: memeTitle.parsed,
isFavorite: memeIsFavorite,
@@ -73,9 +90,7 @@ const AddMeme = ({
tag.memesLength = tag.memes.length;
});
});
goBack();
};
}, [goBack, memeIsFavorite, memeTags, memeTitle.parsed, realm, storageUri]);
return (
<>
@@ -99,20 +114,46 @@ const AddMeme = ({
]}>
<View style={[styles.flex, styles.justifyStart]}>
<MemeEditor
imageUri={file.uri}
memeUri={memeUri}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeTags={memeTags}
setMemeTags={setMemeTags}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
<View style={[styles.flexRow, styles.fullWidth]}>
<Button
mode="contained-tonal"
icon="plus"
onPress={async () => {
setIsSavingAndAddingAnother(true);
await handleSave();
setIsSavingAndAddingAnother(false);
file.current = (await pickSingle({
type: allowedMimeTypes,
}).catch(goBack)) as DocumentPickerResponse;
setMemeUri(file.current.uri);
setMemeTitle(validateMemeTitle('New Meme'));
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
}}
disabled={!memeTitle.valid || isSaving || isSavingAndAddingAnother}
loading={isSavingAndAddingAnother}
style={addMemeStyles.saveAndAddButton}>
Save & Add
</Button>
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!memeTitle.valid || isSaving}
loading={isSaving}>
onPress={async () => {
setIsSaving(true);
await handleSave();
setIsSaving(false);
goBack();
}}
disabled={!memeTitle.valid || isSaving || isSavingAndAddingAnother}
loading={isSaving}
style={addMemeStyles.saveButton}>
Save
</Button>
</View>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ScrollView, View } from 'react-native';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { useRealm } from '@realm/react';
@@ -13,6 +13,17 @@ import { ORIENTATION, useDimensions } from '../contexts';
import { Tag } from '../database';
import { TagEditor } from '../components';
const addTagStyles = StyleSheet.create({
saveAndAddButton: {
flex: 1,
marginRight: 5,
},
saveButton: {
flex: 1,
marginLeft: 5,
},
});
const AddTag = () => {
const { goBack } = useNavigation();
const { colors } = useTheme();
@@ -24,16 +35,19 @@ const AddTag = () => {
validateColor(generateRandomColor()),
);
const handleSave = () => {
// Although saving tags is instantaneous, we still want to show a loading
// indicator to prevent the user from spamming the save button.
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const handleSave = useCallback(() => {
realm.write(() => {
realm.create(Tag.schema.name, {
name: tagName.parsed,
color: tagColor.parsed,
});
});
goBack();
};
}, [realm, tagColor.parsed, tagName.parsed]);
return (
<>
@@ -59,12 +73,31 @@ const AddTag = () => {
setTagColor={setTagColor}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
<View style={[styles.flexRow, styles.fullWidth]}>
<Button
mode="contained-tonal"
icon="plus"
onPress={() => {
setIsSavingAndAddingAnother(true);
handleSave();
setTimeout(() => setIsSavingAndAddingAnother(false), 250);
setTagName(validateTagName('newTag'));
setTagColor(validateColor(generateRandomColor()));
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
loading={isSavingAndAddingAnother}
style={addTagStyles.saveAndAddButton}>
Save & Add
</Button>
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!tagName.valid || !tagColor.valid}>
onPress={() => {
handleSave();
goBack();
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
style={addTagStyles.saveButton}>
Save
</Button>
</View>

View File

@@ -1,16 +1,15 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useObject, useRealm } from '@realm/react';
import { FileSystem } from 'react-native-file-access';
import { BSON } from 'realm';
import { ORIENTATION, useDimensions } from '../contexts';
import styles from '../styles';
import { RootStackParamList, ROUTE } from '../types';
import { Tag, Meme } from '../database';
import { validateMemeTitle } from '../utilities';
import { deleteMeme, favoriteMeme, validateMemeTitle } from '../utilities';
import { MemeEditor } from '../components';
const EditMeme = ({
@@ -34,9 +33,7 @@ const EditMeme = ({
const [isSaving, setIsSaving] = useState(false);
const handleSave = () => {
setIsSaving(true);
const handleSave = useCallback(() => {
realm.write(() => {
meme.tags.forEach(tag => {
if (!memeTags.has(tag.id.toHexString())) {
@@ -60,32 +57,7 @@ const EditMeme = ({
meme.tagsLength = memeTags.size;
meme.dateModified = new Date();
});
goBack();
};
const handleFavorite = () => {
realm.write(() => {
meme.isFavorite = !meme.isFavorite;
});
};
const handleDelete = async () => {
setIsSaving(true);
await FileSystem.unlink(meme.uri);
realm.write(() => {
for (const tag of meme.tags) {
tag.dateModified = new Date();
tag.memes.slice(tag.memes.indexOf(meme), 1);
tag.memesLength -= 1;
}
realm.delete(meme);
});
goBack();
};
}, [meme, memeTags, memeTitle.parsed, realm]);
return (
<>
@@ -94,9 +66,17 @@ const EditMeme = ({
<Appbar.Content title={'Edit Meme'} />
<Appbar.Action
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
onPress={handleFavorite}
onPress={() => favoriteMeme(realm, meme)}
/>
<Appbar.Action
icon="delete"
onPress={async () => {
setIsSaving(true);
await deleteMeme(realm, meme);
setIsSaving(false);
goBack();
}}
/>
<Appbar.Action icon="delete" onPress={handleDelete} />
</Appbar.Header>
<ScrollView
contentContainerStyle={[
@@ -110,7 +90,7 @@ const EditMeme = ({
]}>
<View style={[styles.flex, styles.justifyStart]}>
<MemeEditor
imageUri={meme.uri}
memeUri={meme.uri}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeTags={memeTags}
@@ -121,7 +101,12 @@ const EditMeme = ({
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
onPress={() => {
setIsSaving(true);
handleSave();
setIsSaving(false);
goBack();
}}
disabled={!memeTitle.valid || isSaving}
loading={isSaving}>
Save

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
@@ -10,7 +10,7 @@ import styles from '../styles';
import { ORIENTATION, useDimensions } from '../contexts';
import { ROUTE, RootStackParamList } from '../types';
import { Tag } from '../database';
import { validateColor, validateTagName } from '../utilities';
import { deleteTag, validateColor, validateTagName } from '../utilities';
const EditTag = ({
route,
@@ -29,36 +29,26 @@ const EditTag = ({
const [tagName, setTagName] = useState(validateTagName(tag.name));
const [tagColor, setTagColor] = useState(validateColor(tag.color));
const handleSave = () => {
const handleSave = useCallback(() => {
realm.write(() => {
tag.name = tagName.parsed;
tag.color = tagColor.parsed;
tag.dateModified = new Date();
});
goBack();
};
const handleDelete = () => {
realm.write(() => {
for (const meme of tag.memes) {
meme.dateModified = new Date();
meme.tags.slice(meme.tags.indexOf(tag), 1);
meme.tagsLength -= 1;
}
realm.delete(tag);
});
goBack();
};
}, [realm, tag, tagColor.parsed, tagName.parsed]);
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Edit Tag'} />
<Appbar.Action icon="delete" onPress={handleDelete} />
<Appbar.Action
icon="delete"
onPress={() => {
deleteTag(realm, tag);
goBack();
}}
/>
</Appbar.Header>
<ScrollView
contentContainerStyle={[
@@ -83,7 +73,10 @@ const EditTag = ({
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
onPress={() => {
handleSave();
goBack();
}}
disabled={!tagName.valid || !tagColor.valid}>
Save
</Button>

View File

@@ -3,6 +3,7 @@ export { default as AddTag } from './addTag';
export { default as EditMeme } from './editMeme';
export { default as EditTag } from './editTag';
export { default as Memes } from './memes';
export { default as MemeView } from './memeView';
export { default as Settings } from './settings';
export { default as Tags } from './tags';
export { default as Welcome } from './welcome';

136
src/screens/memeView.tsx Normal file
View File

@@ -0,0 +1,136 @@
import React, { useRef, useState } from 'react';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { StyleSheet, View } from 'react-native';
import { useQuery, useRealm } from '@realm/react';
import { FlashList } from '@shopify/flash-list';
import { Appbar, Portal, Snackbar } from 'react-native-paper';
import { RootStackParamList, ROUTE } from '../types';
import { Meme } from '../database';
import { useDimensions } from '../contexts';
import { MemeViewItem } from '../components';
import {
copyMeme,
deleteMeme,
editMeme,
favoriteMeme,
multipleIdQuery,
shareMeme,
} from '../utilities';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import styles from '../styles';
const memeViewStyles = StyleSheet.create({
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 80,
},
snackbar: {
marginBottom: 90,
},
});
const MemeView = ({
route,
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
const { orientation, dimensions } = useDimensions();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const realm = useRealm();
const { ids } = route.params;
const [index, setIndex] = useState(route.params.index);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const flashListRef = useRef<FlashList<Meme>>(null);
const memes = useQuery<Meme>(Meme.schema.name, collectionIn => {
return collectionIn.filtered(multipleIdQuery(ids));
});
if (memes.length === 0) return <></>;
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<Appbar.Content title={memes[index].title} />
</Appbar.Header>
<View>
<FlashList
ref={flashListRef}
key={orientation}
data={memes}
initialScrollIndex={index}
onScroll={event => {
const newIndex = Math.round(
event.nativeEvent.contentOffset.x /
event.nativeEvent.layoutMeasurement.width,
);
if (newIndex !== index) setIndex(newIndex);
}}
estimatedItemSize={dimensions.width}
pagingEnabled
horizontal
showsHorizontalScrollIndicator={false}
estimatedListSize={{
height: dimensions.height - 160,
width: dimensions.width,
}}
renderItem={({ item: meme }) => <MemeViewItem meme={meme} />}
/>
</View>
<Appbar style={[memeViewStyles.footer, styles.flexRowSpaceEvenly]}>
<Appbar.Action
icon={memes[index].isFavorite ? 'heart' : 'heart-outline'}
onPress={() => favoriteMeme(realm, memes[index])}
/>
<Appbar.Action icon="share" onPress={() => shareMeme(memes[index])} />
<Appbar.Action
icon="content-copy"
onPress={() => {
copyMeme(memes[index]);
setSnackbarMessage('Meme copied!');
setSnackbarVisible(true);
}}
/>
<Appbar.Action
icon="pencil"
onPress={() => {
editMeme(navigation, memes[index]);
}}
/>
<Appbar.Action
icon="delete"
onPress={() => {
if (index === memes.length - 1) {
setIndex(index - 1);
flashListRef.current?.scrollToIndex({
index: index - 1,
});
}
void deleteMeme(realm, memes[index]);
if (memes.length === 1) navigation.goBack();
}}
/>
</Appbar>
<Portal>
<Snackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
style={memeViewStyles.snackbar}
action={{
label: 'Dismiss',
onPress: () => setSnackbarVisible(false),
}}>
{snackbarMessage}
</Snackbar>
</Portal>
</>
);
};
export default MemeView;

View File

@@ -1,4 +1,4 @@
import React, { RefObject, useCallback, useRef, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import {
BackHandler,
NativeScrollEvent,
@@ -9,71 +9,22 @@ import { useQuery } from '@realm/react';
import { useTheme } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import { FlashList } from '@shopify/flash-list';
import { useFocusEffect } from '@react-navigation/native';
import {
ParamListBase,
useFocusEffect,
useNavigation,
} from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import styles from '../styles';
import { SORT_DIRECTION, VIEW, memesSortQuery } from '../types';
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../types';
import { RootState, setNavVisible } from '../state';
import { Meme } from '../database';
import {
HideableHeader,
MemesHeader,
MemesMasonryView,
MemesGridView,
MemesListView,
} from '../components';
const MemesView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const view = useSelector((state: RootState) => state.memes.view);
switch (view) {
case VIEW.MASONRY: {
return (
<MemesMasonryView
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
/>
);
}
case VIEW.GRID: {
return (
<MemesGridView
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
/>
);
}
case VIEW.LIST: {
return (
<MemesListView
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
/>
);
}
default: {
return <></>;
}
}
};
import { HideableHeader, MemesHeader, MemesList } from '../components';
const Memes = () => {
const { colors } = useTheme();
const { navigate } =
useNavigation<NativeStackNavigationProp<ParamListBase>>();
const sort = useSelector((state: RootState) => state.memes.sort);
const sortDirection = useSelector(
(state: RootState) => state.memes.sortDirection,
@@ -154,11 +105,9 @@ const Memes = () => {
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
if (scrollOffset > 0) {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
}
return false;
if (scrollOffset <= 0) return false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
};
BackHandler.addEventListener('hardwareBackPress', handleBackPress);
@@ -190,11 +139,17 @@ const Memes = () => {
}}
/>
</HideableHeader>
<MemesView
<MemesList
memes={memes}
flashListRef={flashListRef}
flashListPadding={flashListPadding}
handleScroll={handleScroll}
focusMeme={(index: number) => {
navigate(ROUTE.MEME_VIEW, {
ids: memes.map(meme => meme.id.toHexString()),
index,
});
}}
/>
</View>
);

View File

@@ -19,12 +19,12 @@ import { SORT_DIRECTION, tagSortQuery } from '../types';
import { ORIENTATION, useDimensions } from '../contexts';
const tagsStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
},
helperText: {
marginVertical: 15,
},
});
const Tags = () => {