diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index cb64fb0..2df2526 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -28,6 +28,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -37,6 +52,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/memes/memeEditor.tsx b/src/components/memes/memeEditor.tsx
index 5bbeca6..7e36d63 100644
--- a/src/components/memes/memeEditor.tsx
+++ b/src/components/memes/memeEditor.tsx
@@ -1,13 +1,20 @@
-import React, { useState } from 'react';
+import React, { useMemo } from 'react';
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper';
import { Image, LayoutAnimation } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { LoadingView, MemeFail, MemeTagSelector } from '..';
-import { getFilenameFromUri, validateMemeTitle } from '../../utilities';
-import { Dimensions, StagingMeme } from '../../types';
+import {
+ getFilenameFromUri,
+ getMemeTypeFromMimeType,
+ validateMemeTitle,
+} from '../../utilities';
+import { StagingMeme } from '../../types';
+import { useMemeDimensions } from '../../hooks';
+import { MEME_TYPE } from '../../database';
+import Video from 'react-native-video';
const memeEditorStyles = {
- image: {
+ media: {
marginBottom: 15,
borderRadius: 5,
},
@@ -26,6 +33,7 @@ const memeEditorStyles = {
const MemeEditor = ({
uri,
mimeType,
+ loading,
setLoading,
error,
setError,
@@ -44,7 +52,59 @@ const MemeEditor = ({
const { width } = useSafeAreaFrame();
const { colors } = useTheme();
- const [dimensions, setDimensions] = useState();
+ const { dimensions } = useMemeDimensions(
+ uri,
+ mimeType,
+ useMemo(
+ () => () => {
+ setLoading(false);
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ },
+ [setLoading],
+ ),
+ useMemo(() => (errorIn: Error) => setError(errorIn), [setError]),
+ );
+
+ const mediaComponent = useMemo(() => {
+ if (!mimeType || !dimensions) return <>>;
+
+ const dimensionStyles = {
+ width: width * 0.92,
+ height: Math.max(
+ Math.min((width * 0.92) / dimensions.aspectRatio, 500),
+ 100,
+ ),
+ };
+
+ const memeType = getMemeTypeFromMimeType(mimeType);
+ if (!memeType) return <>>;
+
+ switch (memeType) {
+ case MEME_TYPE.IMAGE:
+ case MEME_TYPE.GIF: {
+ return (
+
+ );
+ }
+ case MEME_TYPE.VIDEO: {
+ return (
+
+ );
+ }
+ default: {
+ return <>>;
+ }
+ }
+ }, [dimensions, mimeType, uri, width]);
if (!uri || !mimeType || !staging) return ;
@@ -70,51 +130,15 @@ const MemeEditor = ({
width: width * 0.92,
height: width * 0.92,
},
- memeEditorStyles.image,
+ memeEditorStyles.media,
]}
iconSize={50}
/>
+ ) : // eslint-disable-next-line unicorn/no-nested-ternary
+ loading || !dimensions ? (
+ <>>
) : (
- {
- setDimensions({
- width: event.nativeEvent.source.width,
- height: event.nativeEvent.source.height,
- });
- setLoading(false);
- LayoutAnimation.configureNext(
- LayoutAnimation.Presets.easeInEaseOut,
- );
- }}
- 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.',
- ),
- )
- }
- />
+ mediaComponent
)}
{
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
- const { dimensions, loading, error } = useImageDimensions({ uri });
+ const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
+
+ const mediaComponent = useMemo(() => {
+ if (!dimensions) return <>>;
+
+ const dimensionStyles =
+ dimensions.aspectRatio > width / (height - 128)
+ ? {
+ width,
+ height: width / (dimensions.width / dimensions.height),
+ }
+ : {
+ width: (height - 128) * (dimensions.width / dimensions.height),
+ height: height - 128,
+ };
+
+ switch (meme.memeType) {
+ case MEME_TYPE.IMAGE:
+ case MEME_TYPE.GIF: {
+ return ;
+ }
+ default: {
+ return (
+
+ );
+ }
+ }
+ }, [dimensions, height, meme.memeType, uri, width]);
if (!error && (loading || !dimensions)) {
return ;
@@ -41,21 +69,7 @@ const MemeViewItem = ({ meme }: { meme: Meme }) => {
iconSize={50}
/>
) : (
- width / (height - 128)
- ? {
- width,
- height: width / (dimensions.width / dimensions.height),
- }
- : {
- width:
- (height - 128) * (dimensions.width / dimensions.height),
- height: height - 128,
- }
- }
- />
+ mediaComponent
)}
);
diff --git a/src/components/memes/memesList/memesGridItem.tsx b/src/components/memes/memesList/memesGridItem.tsx
index 4f6e1df..da62b64 100644
--- a/src/components/memes/memesList/memesGridItem.tsx
+++ b/src/components/memes/memesList/memesGridItem.tsx
@@ -1,13 +1,13 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { Image, TouchableHighlight } from 'react-native';
import { useSelector } from 'react-redux';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
-import { useImageDimensions } from '@react-native-community/hooks';
import { AndroidScoped } from 'react-native-file-access';
-import { Meme } from '../../../database';
+import { MEME_TYPE, Meme } from '../../../database';
import { RootState } from '../../../state';
import { MemeFail } from '..';
import { getFontAwesome5IconSize } from '../../../utilities';
+import { useMemeDimensions } from '../../../hooks';
const MemesGridItem = ({
meme,
@@ -29,7 +29,32 @@ const MemesGridItem = ({
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
- const { dimensions, loading, error } = useImageDimensions({ uri });
+ const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
+
+ const itemWidth = (width * 0.92 - 5) / gridColumns;
+
+ const mediaComponent = useMemo(() => {
+ switch (meme.memeType) {
+ case MEME_TYPE.IMAGE:
+ case MEME_TYPE.GIF:
+ case MEME_TYPE.VIDEO: {
+ return (
+
+ );
+ }
+ default: {
+ return <>>;
+ }
+ }
+ }, [itemWidth, meme.memeType, uri]);
if (!error && (loading || !dimensions)) return <>>;
@@ -44,15 +69,7 @@ const MemesGridItem = ({
iconSize={getFontAwesome5IconSize(gridColumns)}
/>
) : (
-
+ mediaComponent
)}
);
diff --git a/src/components/memes/memesList/memesListItem.tsx b/src/components/memes/memesList/memesListItem.tsx
index cdc2a86..f4f588b 100644
--- a/src/components/memes/memesList/memesListItem.tsx
+++ b/src/components/memes/memesList/memesListItem.tsx
@@ -1,13 +1,13 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { Image, StyleSheet, View } from 'react-native';
import { Text, TouchableRipple } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
-import { useImageDimensions } from '@react-native-community/hooks';
import { AndroidScoped } from 'react-native-file-access';
import { useSelector } from 'react-redux';
-import { Meme } from '../../../database';
+import { MEME_TYPE, Meme } from '../../../database';
import { MemeFail } from '..';
import { RootState } from '../../../state';
+import { useMemeDimensions } from '../../../hooks';
const memesListItemStyles = StyleSheet.create({
view: {
@@ -50,7 +50,20 @@ const MemesListItem = ({
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
- const { dimensions, loading, error } = useImageDimensions({ uri });
+ const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
+
+ const mediaComponent = useMemo(() => {
+ switch (meme.memeType) {
+ case MEME_TYPE.IMAGE:
+ case MEME_TYPE.GIF:
+ case MEME_TYPE.VIDEO: {
+ return ;
+ }
+ default: {
+ return <>>;
+ }
+ }
+ }, [meme.memeType, uri]);
if (!error && (loading || !dimensions)) return <>>;
@@ -62,7 +75,7 @@ const MemesListItem = ({
{error ? (
) : (
-
+ mediaComponent
)}
{
+ switch (meme.memeType) {
+ case MEME_TYPE.IMAGE:
+ case MEME_TYPE.GIF:
+ case MEME_TYPE.VIDEO: {
+ return (
+
+ );
+ }
+ default: {
+ return <>>;
+ }
+ }
+ }, [itemHeight, itemWidth, meme.memeType, uri]);
if (!error && (loading || !dimensions)) return <>>;
@@ -51,25 +79,12 @@ const MemesMasonryItem = ({
) : (
-
+ mediaComponent
)}
);
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..21f6042
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1 @@
+export { default as useMemeDimensions } from './useMemeDimensions';
diff --git a/src/hooks/useMemeDimensions.ts b/src/hooks/useMemeDimensions.ts
new file mode 100644
index 0000000..5f12eb0
--- /dev/null
+++ b/src/hooks/useMemeDimensions.ts
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react';
+import { Image } from 'react-native';
+import { Dimensions } from '../types';
+
+const useMediaDimensions = (
+ uri?: string,
+ mimeType?: string,
+ onLoad?: (width: number, height: number, aspectRatio: number) => void,
+ onError?: (error: Error) => void,
+) => {
+ const [dimensions, setDimensions] = useState();
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState();
+
+ useEffect(() => {
+ const getDimensions = () => {
+ if (!uri || !mimeType) return;
+
+ const mimeStart = mimeType.split('/')[0];
+
+ switch (mimeStart) {
+ case 'image':
+ case 'video': {
+ Image.getSize(
+ uri,
+ (width, height) => {
+ const aspectRatio = width / height;
+ setDimensions({ width, height, aspectRatio });
+ setLoading(false);
+ onLoad?.(width, height, aspectRatio);
+ },
+ (errorIn: string) => {
+ const errorOut = new Error(errorIn);
+ setError(errorOut);
+ setLoading(false);
+ onError?.(errorOut);
+ },
+ );
+ break;
+ }
+ default: {
+ const errorOut = new Error(`Unknown mime type: ${mimeType}`);
+ setError(errorOut);
+ setLoading(false);
+ onError?.(errorOut);
+ }
+ }
+ };
+
+ getDimensions();
+ }, [mimeType, onError, onLoad, uri]);
+
+ return { dimensions, loading, error };
+};
+
+export default useMediaDimensions;
diff --git a/src/screens/memeView.tsx b/src/screens/memeView.tsx
index 812f1d4..4f9568b 100644
--- a/src/screens/memeView.tsx
+++ b/src/screens/memeView.tsx
@@ -6,7 +6,13 @@ import { FlashList } from '@shopify/flash-list';
import { Appbar, Portal, Snackbar } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { NavigationProp, useNavigation } from '@react-navigation/native';
-import { RootStackParamList, ROUTE } from '../types';
+import { useSelector } from 'react-redux';
+import {
+ memesSortQuery,
+ RootStackParamList,
+ ROUTE,
+ SORT_DIRECTION,
+} from '../types';
import { Meme } from '../database';
import { LoadingView, MemeViewItem } from '../components';
import {
@@ -17,7 +23,6 @@ import {
multipleIdQuery,
shareMeme,
} from '../utilities';
-import { useSelector } from 'react-redux';
import { RootState } from '../state';
const memeViewStyles = StyleSheet.create({
@@ -50,6 +55,10 @@ const MemeView = ({
}: NativeStackScreenProps) => {
const { height, width } = useSafeAreaFrame();
const navigation = useNavigation>();
+ const sort = useSelector((state: RootState) => state.memes.sort);
+ const sortDirection = useSelector(
+ (state: RootState) => state.memes.sortDirection,
+ );
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
@@ -61,15 +70,20 @@ const MemeView = ({
const flashListRef = useRef>(null);
- const [index, setIndex] = useState(route.params.index);
+ const index = useRef(route.params.index);
const memes = useQuery(Meme.schema.name, collectionIn => {
- return collectionIn.filtered(multipleIdQuery(route.params.ids));
+ return collectionIn
+ .filtered(multipleIdQuery(route.params.ids))
+ .sorted(
+ memesSortQuery(sort),
+ sortDirection === SORT_DIRECTION.DESCENDING,
+ );
});
useEffect(() => {
if (memes.length === 0) navigation.goBack();
- if (index >= memes.length) {
- setIndex(memes.length - 1);
+ if (index.current >= memes.length) {
+ index.current = memes.length - 1;
flashListRef.current?.scrollToIndex({ index: memes.length - 1 });
}
}, [index, memes.length, navigation]);
@@ -79,19 +93,19 @@ const MemeView = ({
navigation.goBack()} />
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
-
+
{
const newIndex = Math.round(
event.nativeEvent.contentOffset.x /
event.nativeEvent.layoutMeasurement.width,
);
- if (newIndex !== index) setIndex(newIndex);
+ if (newIndex !== index.current) index.current = newIndex;
}}
estimatedItemSize={width}
estimatedListSize={{ height, width }}
@@ -111,14 +125,14 @@ const MemeView = ({
favoriteMeme(realm, memes[index])}
+ icon={memes[index.current]?.isFavorite ? 'heart' : 'heart-outline'}
+ onPress={() => favoriteMeme(realm, memes[index.current])}
disabled={isBlocked}
/>
{
- shareMeme(realm, storageUri, memes[index]).catch(() =>
+ shareMeme(realm, storageUri, memes[index.current]).catch(() =>
setSnackbarMessage('Failed to share meme!'),
);
}}
@@ -127,7 +141,7 @@ const MemeView = ({
{
- await copyMeme(realm, storageUri, memes[index])
+ await copyMeme(realm, storageUri, memes[index.current])
.then(() => setSnackbarMessage('Meme copied!'))
.catch(() => setSnackbarMessage('Failed to copy meme!'));
}}
@@ -136,7 +150,7 @@ const MemeView = ({
{
- editMeme(navigation, memes[index]);
+ editMeme(navigation, memes[index.current]);
}}
disabled={isBlocked}
/>
@@ -144,7 +158,7 @@ const MemeView = ({
icon="delete"
onPress={async () => {
setIsBlocked(true);
- await deleteMeme(realm, storageUri, memes[index]);
+ await deleteMeme(realm, storageUri, memes[index.current]);
setIsBlocked(false);
}}
disabled={isBlocked}
diff --git a/src/screens/memes.tsx b/src/screens/memes.tsx
index 73c3ae8..db0aefc 100644
--- a/src/screens/memes.tsx
+++ b/src/screens/memes.tsx
@@ -98,7 +98,7 @@ const Memes = () => {
[sort, sortDirection, favoritesOnly, filter, search],
);
- const [scrollOffset, setScrollOffset] = useState(0);
+ const previousOffset = useRef(0);
const handleScroll = (event: NativeSyntheticEvent) => {
const currentOffset = event.nativeEvent.contentOffset.y;
@@ -106,13 +106,13 @@ const Memes = () => {
if (currentOffset <= 150) {
dispatch(setNavVisible(true));
} else {
- const diff = currentOffset - scrollOffset;
- if (Math.abs(diff) > 50) {
+ const diff = currentOffset - previousOffset.current;
+ if (Math.abs(diff) > 35) {
dispatch(setNavVisible(diff < 0));
}
}
- setScrollOffset(currentOffset);
+ previousOffset.current = currentOffset;
};
const flashListRef = useRef>(null);
@@ -120,7 +120,7 @@ const Memes = () => {
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
- if (scrollOffset <= 0) return false;
+ if (previousOffset.current <= 0) return false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
};
@@ -129,7 +129,7 @@ const Memes = () => {
return () =>
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
- }, [scrollOffset]),
+ }, []),
);
useFocusEffect(
diff --git a/src/screens/tags.tsx b/src/screens/tags.tsx
index 46aa9b9..b18a00c 100644
--- a/src/screens/tags.tsx
+++ b/src/screens/tags.tsx
@@ -71,7 +71,7 @@ const Tags = () => {
[search, sort, sortDirection],
);
- const [scrollOffset, setScrollOffset] = useState(0);
+ const previousOffset = useRef(0);
const handleScroll = (event: NativeSyntheticEvent) => {
const currentOffset = event.nativeEvent.contentOffset.y;
@@ -79,11 +79,11 @@ const Tags = () => {
if (currentOffset <= 150) {
dispatch(setNavVisible(true));
} else {
- const diff = currentOffset - scrollOffset;
+ const diff = currentOffset - previousOffset.current;
if (Math.abs(diff) > 50) dispatch(setNavVisible(diff < 0));
}
- setScrollOffset(currentOffset);
+ previousOffset.current = currentOffset;
};
const flashListRef = useRef>(null);
@@ -91,7 +91,7 @@ const Tags = () => {
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
- if (scrollOffset > 0) {
+ if (previousOffset.current > 0) {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
}
@@ -102,7 +102,7 @@ const Tags = () => {
return () =>
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
- }, [scrollOffset]),
+ }, []),
);
useFocusEffect(
diff --git a/src/types/dimensions.ts b/src/types/dimensions.ts
index 1992449..e1c7cf3 100644
--- a/src/types/dimensions.ts
+++ b/src/types/dimensions.ts
@@ -1,6 +1,7 @@
interface Dimensions {
width: number;
height: number;
+ aspectRatio: number;
}
export { type Dimensions };
diff --git a/src/utilities/filesystem.ts b/src/utilities/filesystem.ts
index 1c06270..09e4ea8 100644
--- a/src/utilities/filesystem.ts
+++ b/src/utilities/filesystem.ts
@@ -1,5 +1,6 @@
import { FileSystem } from 'react-native-file-access';
import filetypemime from 'magic-bytes.js';
+import { lookup } from 'react-native-mime-types';
import { MEME_TYPE } from '../database';
const allowedImageMimeTypes = [
@@ -12,43 +13,52 @@ const allowedImageMimeTypes = [
const allowedGifMimeTypes = ['image/gif'];
-const allowedMimeTypes = [...allowedImageMimeTypes, ...allowedGifMimeTypes];
+const allowedVideoMimeTypes = [
+ 'video/av01',
+ 'video/3gpp',
+ 'video/avc',
+ 'video/hevc',
+ 'video/x-matroska',
+ 'video/mp2t',
+ 'video/mp4',
+ 'video/mp42',
+ 'video/mp43',
+ 'video/mp4v-es',
+ 'video/mpeg',
+ 'video/mpeg2',
+ 'video/x-vnd.on2.vp8',
+ 'video/x-vnd.on2.vp9',
+ 'video/webm',
+];
+
+const allowedMimeTypes = [
+ ...allowedImageMimeTypes,
+ ...allowedGifMimeTypes,
+ ...allowedVideoMimeTypes,
+];
const getMemeTypeFromMimeType = (mimeType: string): MEME_TYPE | undefined => {
- switch (mimeType) {
- case 'image/bmp':
- case 'image/jpg':
- case 'image/jpeg':
- case 'image/png':
- case 'image/webp': {
+ if (!allowedMimeTypes.includes(mimeType)) return undefined;
+
+ const mimeStart = mimeType.split('/')[0];
+
+ switch (mimeStart) {
+ case 'image': {
+ if (mimeType === 'image/gif') return MEME_TYPE.GIF;
return MEME_TYPE.IMAGE;
}
- case 'image/gif': {
- return MEME_TYPE.GIF;
+ case 'video': {
+ return MEME_TYPE.VIDEO;
}
}
};
const guessMimeTypeFromExtension = (filename: string): string | undefined => {
const extension = filename.split('.').pop()?.toLowerCase();
- switch (extension) {
- case 'bmp': {
- return 'image/bmp';
- }
- case 'jpg':
- case 'jpeg': {
- return 'image/jpeg';
- }
- case 'png': {
- return 'image/png';
- }
- case 'webp': {
- return 'image/webp';
- }
- case 'gif': {
- return 'image/gif';
- }
- }
+ if (!extension) return undefined;
+ const guessedMimeType = lookup(extension);
+ if (!guessedMimeType) return undefined;
+ return guessedMimeType;
};
const guessMimeTypeFromMagicBytes = async (
@@ -66,10 +76,15 @@ const guessMimeType = async (
): Promise => {
if (hint && allowedMimeTypes.includes(hint)) return hint;
- const guessedMimeType = guessMimeTypeFromExtension(uri);
- if (guessedMimeType) return guessedMimeType;
+ let guessedMimeType = guessMimeTypeFromExtension(uri);
+ if (guessedMimeType && allowedMimeTypes.includes(guessedMimeType)) {
+ return guessedMimeType;
+ }
- return await guessMimeTypeFromMagicBytes(uri);
+ guessedMimeType = await guessMimeTypeFromMagicBytes(uri);
+ if (guessedMimeType && allowedMimeTypes.includes(guessedMimeType)) {
+ return guessedMimeType;
+ }
};
export {