Add memes views & searching

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-23 20:20:11 +03:00
parent e44ee7de34
commit 04661ca356
28 changed files with 737 additions and 247 deletions

104
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"react-native-file-access": "^3.0.4", "react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0", "react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0", "react-native-get-random-values": "^1.9.0",
"react-native-image-zoom-viewer": "^3.0.1",
"react-native-mime-types": "^2.4.0", "react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1", "react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0", "react-native-reanimated": "^3.3.0",
@@ -31,7 +32,7 @@
"react-native-screens": "^3.22.1", "react-native-screens": "^3.22.1",
"react-native-share": "^9.2.3", "react-native-share": "^9.2.3",
"react-native-vector-icons": "^9.2.0", "react-native-vector-icons": "^9.2.0",
"react-native-video": "^5.2.1", "react-native-video": "^6.0.0-alpha.6",
"react-redux": "^8.1.1", "react-redux": "^8.1.1",
"realm": "^11.10.1", "realm": "^11.10.1",
"redux-persist": "^6.0.0" "redux-persist": "^6.0.0"
@@ -48,6 +49,7 @@
"@types/metro-config": "^0.76.3", "@types/metro-config": "^0.76.3",
"@types/react": "^18.2.14", "@types/react": "^18.2.14",
"@types/react-native-vector-icons": "^6.4.13", "@types/react-native-vector-icons": "^6.4.13",
"@types/react-native-video": "^5.0.15",
"@types/react-test-renderer": "^18.0.0", "@types/react-test-renderer": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0", "@typescript-eslint/parser": "^5.61.0",
@@ -4602,6 +4604,16 @@
"@types/react-native": "^0.70" "@types/react-native": "^0.70"
} }
}, },
"node_modules/@types/react-native-video": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.15.tgz",
"integrity": "sha512-li3yBYQ+D5GqZl0Y+M/vCTPfZwVyUU67CtSjEg+/ERkgEpvHDH+gQaoc9O00ttXr8kvqEzpiC6Ca9juIfeIlMA==",
"dev": true,
"dependencies": {
"@types/react": "*",
"@types/react-native": "*"
}
},
"node_modules/@types/react-test-renderer": { "node_modules/@types/react-test-renderer": {
"version": "18.0.0", "version": "18.0.0",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.0.tgz",
@@ -6541,11 +6553,6 @@
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.455.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.455.tgz",
"integrity": "sha512-8tgdX0Odl24LtmLwxotpJCVjIndN559AvaOtd67u+2mo+IDsgsTF580NB+uuDCqsHw8yFg53l5+imFV9Fw3cbA==" "integrity": "sha512-8tgdX0Odl24LtmLwxotpJCVjIndN559AvaOtd67u+2mo+IDsgsTF580NB+uuDCqsHw8yFg53l5+imFV9Fw3cbA=="
}, },
"node_modules/eme-encryption-scheme-polyfill": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/eme-encryption-scheme-polyfill/-/eme-encryption-scheme-polyfill-2.1.1.tgz",
"integrity": "sha512-njD17wcUrbqCj0ArpLu5zWXtaiupHb/2fIUQGdInf83GlI+Q6mmqaPGLdrke4savKAu15J/z1Tg/ivDgl14g0g=="
},
"node_modules/emittery": { "node_modules/emittery": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@@ -13407,6 +13414,27 @@
"react-native": ">=0.56" "react-native": ">=0.56"
} }
}, },
"node_modules/react-native-image-pan-zoom": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/react-native-image-pan-zoom/-/react-native-image-pan-zoom-2.1.12.tgz",
"integrity": "sha512-BF66XeP6dzuANsPmmFsJshM2Jyh/Mo1t8FsGc1L9Q9/sVP8MJULDabB1hms+eAoqgtyhMr5BuXV3E1hJ5U5H6Q==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-image-zoom-viewer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/react-native-image-zoom-viewer/-/react-native-image-zoom-viewer-3.0.1.tgz",
"integrity": "sha512-la6s5DNSuq4GCRLsi5CZ29FPjgTpdCuGIRdO5T9rUrAtxrlpBPhhSnHrbmPVxsdtOUvxHacTh2Gfa9+RraMZQA==",
"dependencies": {
"react-native-image-pan-zoom": "^2.1.12"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-mime-types": { "node_modules/react-native-mime-types": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz", "resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz",
@@ -13528,14 +13556,13 @@
} }
}, },
"node_modules/react-native-video": { "node_modules/react-native-video": {
"version": "5.2.1", "version": "6.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-5.2.1.tgz", "resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.0.0-alpha.6.tgz",
"integrity": "sha512-aJlr9MeTuQ0LpZ4n+EC9RvhoKeiPbLtI2Rxy8u7zo/wzGevbRpWHSBj9xZ5YDBXnAVXzuqyNIkGhdw7bfdIBZw==", "integrity": "sha512-MCqHfPGuqVokvJOkvidhD5/eGYkWZrDEcSDtlkwVo36V2157L6lZyt3mqb5tPR+e5jSz+c/ht2JpEhP1bCm/Dw==",
"dependencies": { "dependencies": {
"deprecated-react-native-prop-types": "^2.2.0", "deprecated-react-native-prop-types": "^2.2.0",
"keymirror": "^0.1.1", "keymirror": "^0.1.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2"
"shaka-player": "^2.5.9"
} }
}, },
"node_modules/react-native-video/node_modules/deprecated-react-native-prop-types": { "node_modules/react-native-video/node_modules/deprecated-react-native-prop-types": {
@@ -14500,15 +14527,6 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
}, },
"node_modules/shaka-player": {
"version": "2.5.23",
"resolved": "https://registry.npmjs.org/shaka-player/-/shaka-player-2.5.23.tgz",
"integrity": "sha512-3MC9k0OXJGw8AZ4n/ZNCZS2yDxx+3as5KgH6Tx4Q5TRboTBBCu6dYPI5vp1DxKeyU12MBN1Zcbs7AKzXv2EnCg==",
"deprecated": "Shaka Player < v3.2 is no longer supported.",
"dependencies": {
"eme-encryption-scheme-polyfill": "^2.0.1"
}
},
"node_modules/shallow-clone": { "node_modules/shallow-clone": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
@@ -19177,6 +19195,16 @@
"@types/react-native": "^0.70" "@types/react-native": "^0.70"
} }
}, },
"@types/react-native-video": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.15.tgz",
"integrity": "sha512-li3yBYQ+D5GqZl0Y+M/vCTPfZwVyUU67CtSjEg+/ERkgEpvHDH+gQaoc9O00ttXr8kvqEzpiC6Ca9juIfeIlMA==",
"dev": true,
"requires": {
"@types/react": "*",
"@types/react-native": "*"
}
},
"@types/react-test-renderer": { "@types/react-test-renderer": {
"version": "18.0.0", "version": "18.0.0",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.0.tgz",
@@ -20617,11 +20645,6 @@
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.455.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.455.tgz",
"integrity": "sha512-8tgdX0Odl24LtmLwxotpJCVjIndN559AvaOtd67u+2mo+IDsgsTF580NB+uuDCqsHw8yFg53l5+imFV9Fw3cbA==" "integrity": "sha512-8tgdX0Odl24LtmLwxotpJCVjIndN559AvaOtd67u+2mo+IDsgsTF580NB+uuDCqsHw8yFg53l5+imFV9Fw3cbA=="
}, },
"eme-encryption-scheme-polyfill": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/eme-encryption-scheme-polyfill/-/eme-encryption-scheme-polyfill-2.1.1.tgz",
"integrity": "sha512-njD17wcUrbqCj0ArpLu5zWXtaiupHb/2fIUQGdInf83GlI+Q6mmqaPGLdrke4savKAu15J/z1Tg/ivDgl14g0g=="
},
"emittery": { "emittery": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@@ -25788,6 +25811,20 @@
"fast-base64-decode": "^1.0.0" "fast-base64-decode": "^1.0.0"
} }
}, },
"react-native-image-pan-zoom": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/react-native-image-pan-zoom/-/react-native-image-pan-zoom-2.1.12.tgz",
"integrity": "sha512-BF66XeP6dzuANsPmmFsJshM2Jyh/Mo1t8FsGc1L9Q9/sVP8MJULDabB1hms+eAoqgtyhMr5BuXV3E1hJ5U5H6Q==",
"requires": {}
},
"react-native-image-zoom-viewer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/react-native-image-zoom-viewer/-/react-native-image-zoom-viewer-3.0.1.tgz",
"integrity": "sha512-la6s5DNSuq4GCRLsi5CZ29FPjgTpdCuGIRdO5T9rUrAtxrlpBPhhSnHrbmPVxsdtOUvxHacTh2Gfa9+RraMZQA==",
"requires": {
"react-native-image-pan-zoom": "^2.1.12"
}
},
"react-native-mime-types": { "react-native-mime-types": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz", "resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz",
@@ -25879,14 +25916,13 @@
} }
}, },
"react-native-video": { "react-native-video": {
"version": "5.2.1", "version": "6.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-5.2.1.tgz", "resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.0.0-alpha.6.tgz",
"integrity": "sha512-aJlr9MeTuQ0LpZ4n+EC9RvhoKeiPbLtI2Rxy8u7zo/wzGevbRpWHSBj9xZ5YDBXnAVXzuqyNIkGhdw7bfdIBZw==", "integrity": "sha512-MCqHfPGuqVokvJOkvidhD5/eGYkWZrDEcSDtlkwVo36V2157L6lZyt3mqb5tPR+e5jSz+c/ht2JpEhP1bCm/Dw==",
"requires": { "requires": {
"deprecated-react-native-prop-types": "^2.2.0", "deprecated-react-native-prop-types": "^2.2.0",
"keymirror": "^0.1.1", "keymirror": "^0.1.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2"
"shaka-player": "^2.5.9"
}, },
"dependencies": { "dependencies": {
"deprecated-react-native-prop-types": { "deprecated-react-native-prop-types": {
@@ -26511,14 +26547,6 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
}, },
"shaka-player": {
"version": "2.5.23",
"resolved": "https://registry.npmjs.org/shaka-player/-/shaka-player-2.5.23.tgz",
"integrity": "sha512-3MC9k0OXJGw8AZ4n/ZNCZS2yDxx+3as5KgH6Tx4Q5TRboTBBCu6dYPI5vp1DxKeyU12MBN1Zcbs7AKzXv2EnCg==",
"requires": {
"eme-encryption-scheme-polyfill": "^2.0.1"
}
},
"shallow-clone": { "shallow-clone": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",

View File

@@ -28,6 +28,7 @@
"react-native-file-access": "^3.0.4", "react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0", "react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0", "react-native-get-random-values": "^1.9.0",
"react-native-image-zoom-viewer": "^3.0.1",
"react-native-mime-types": "^2.4.0", "react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1", "react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0", "react-native-reanimated": "^3.3.0",
@@ -36,7 +37,7 @@
"react-native-screens": "^3.22.1", "react-native-screens": "^3.22.1",
"react-native-share": "^9.2.3", "react-native-share": "^9.2.3",
"react-native-vector-icons": "^9.2.0", "react-native-vector-icons": "^9.2.0",
"react-native-video": "^5.2.1", "react-native-video": "^6.0.0-alpha.6",
"react-redux": "^8.1.1", "react-redux": "^8.1.1",
"realm": "^11.10.1", "realm": "^11.10.1",
"redux-persist": "^6.0.0" "redux-persist": "^6.0.0"
@@ -53,6 +54,7 @@
"@types/metro-config": "^0.76.3", "@types/metro-config": "^0.76.3",
"@types/react": "^18.2.14", "@types/react": "^18.2.14",
"@types/react-native-vector-icons": "^6.4.13", "@types/react-native-vector-icons": "^6.4.13",
"@types/react-native-video": "^5.0.15",
"@types/react-test-renderer": "^18.0.0", "@types/react-test-renderer": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0", "@typescript-eslint/parser": "^5.61.0",

View File

@@ -1,4 +1,7 @@
export { export {
MemesGridView,
MemesListView,
MemesMasonryView,
MemeEditor, MemeEditor,
MemesHeader, MemesHeader,
MemeTagSearchModal, MemeTagSearchModal,

View File

@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { Image, TouchableHighlight, View } from 'react-native';
import { useSelector } from 'react-redux';
import { Meme } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import { useDimensions } from '../../../contexts';
import { RootState } from '../../../state';
const MemesGridItem = ({ meme }: { meme: Meme }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
const { dimensions } = useDimensions();
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
const [imageWidth, setImageWidth] = useState<number>();
const [imageHeight, setImageHeight] = useState<number>();
Image.getSize(meme.uri, () => {
const paddedWidth = (dimensions.width * 0.92 - 5) / gridColumns;
setImageWidth(paddedWidth);
setImageHeight(paddedWidth);
});
return (
<>
{imageWidth && imageHeight && (
<View>
<TouchableHighlight
onPress={() =>
navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() })
}>
<Image
source={{ uri: meme.uri }}
style={[{ width: imageWidth, height: imageHeight }]}
/>
</TouchableHighlight>
</View>
)}
</>
);
};
export default MemesGridItem;

View File

@@ -0,0 +1,74 @@
import React, { RefObject } from 'react';
import { Meme } from '../../../database';
import { FlashList } from '@shopify/flash-list';
import { HelperText } from 'react-native-paper';
import {
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
} from 'react-native';
import { useSelector } from 'react-redux';
import styles from '../../../styles';
import { RootState } from '../../../state';
import { ORIENTATION, useDimensions } from '../../../contexts';
import { getFlashListItemHeight } from '../../../utilities';
import MemesGridItem from './memesGridItem';
const gridViewStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
paddingHorizontal: 2.5,
},
});
const MemesGridView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const { orientation, dimensions } = useDimensions();
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
return (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={getFlashListItemHeight(gridColumns)}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
numColumns={gridColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme }) => <MemesGridItem meme={meme} />}
contentContainerStyle={{
paddingTop:
flashListPadding +
dimensions.height *
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
...gridViewStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[gridViewStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
);
};
export default MemesGridView;

View File

@@ -1,3 +1,6 @@
export { default as MemesGridView } from './gridView/memesGridView';
export { default as MemesListView } from './listView/memesListView';
export { default as MemesMasonryView } from './masonryView/memesMasonryView';
export { default as MemeEditor } from './memeEditor'; export { default as MemeEditor } from './memeEditor';
export { default as MemesHeader } from './memesHeader'; export { default as MemesHeader } from './memesHeader';
export { default as MemeTagSearchModal } from './memeTagSearchModal'; export { default as MemeTagSearchModal } from './memeTagSearchModal';

View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { Image, StyleSheet, View } from 'react-native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { Text, TouchableRipple } from 'react-native-paper';
import { Meme } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import styles from '../../../styles';
import { useDimensions } from '../../../contexts';
const memesListItemStyles = StyleSheet.create({
view: {
paddingVertical: 10,
},
image: {
borderRadius: 5,
},
detailsView: {
marginLeft: 10,
},
text: {
marginRight: 5,
marginBottom: 5,
},
});
const MemesListItem = ({ meme }: { meme: Meme }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
const { dimensions } = useDimensions();
const [imageWidth, setImageWidth] = useState<number>();
const [imageHeight, setImageHeight] = useState<number>();
Image.getSize(meme.uri, () => {
const paddedWidth = 75;
setImageWidth(paddedWidth);
setImageHeight(paddedWidth);
});
return (
<>
{imageWidth && imageHeight && (
<TouchableRipple
onPress={() =>
navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() })
}
style={[memesListItemStyles.view, styles.flexRow]}>
<>
<View style={{ width: imageWidth, height: imageHeight }}>
<Image
source={{ uri: meme.uri }}
style={[
{ width: imageWidth, height: imageHeight },
memesListItemStyles.image,
]}
/>
</View>
<View
style={[
memesListItemStyles.detailsView,
styles.flexColumn,
{
width: dimensions.width * 0.92 - imageWidth - 10,
},
]}>
<Text variant="titleMedium" style={memesListItemStyles.text}>
{meme.title}
</Text>
<View style={styles.flexRow}>
<Text variant="labelSmall" style={memesListItemStyles.text}>
{meme.dateModified.toLocaleDateString()} {meme.size / 1000}
KB
</Text>
</View>
<View style={[styles.flexRow, styles.flexWrap]}>
{meme.tags.map(tag => (
<Text
variant="labelMedium"
key={tag.id.toHexString()}
style={[
{
color: tag.color,
},
memesListItemStyles.text,
]}
numberOfLines={1}>
#{tag.name}
</Text>
))}
</View>
</View>
</>
</TouchableRipple>
)}
</>
);
};
export default MemesListItem;

View File

@@ -0,0 +1,68 @@
import React, { RefObject } from 'react';
import { Meme } from '../../../database';
import { FlashList } from '@shopify/flash-list';
import { Divider, HelperText } from 'react-native-paper';
import {
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
} from 'react-native';
import styles from '../../../styles';
import { ORIENTATION, useDimensions } from '../../../contexts';
import MemesListItem from './memesListItem';
const gridViewStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
paddingHorizontal: 5,
},
});
const MemesListView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const { orientation, dimensions } = useDimensions();
return (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={50}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme }) => <MemesListItem meme={meme} />}
ItemSeparatorComponent={() => <Divider />}
contentContainerStyle={{
paddingTop:
flashListPadding +
dimensions.height *
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
...gridViewStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[gridViewStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
);
};
export default MemesListView;

View File

@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { Meme } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import { useDimensions } from '../../../contexts';
import { RootState } from '../../../state';
const memeMasonryItemStyles = StyleSheet.create({
view: {
margin: 2.5,
borderRadius: 5,
},
image: {
borderRadius: 5,
},
});
const MemesMasonryItem = ({ meme }: { meme: Meme }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
const { dimensions } = useDimensions();
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
const [imageWidth, setImageWidth] = useState<number>();
const [imageHeight, setImageHeight] = useState<number>();
Image.getSize(meme.uri, (width, height) => {
const paddedWidth = (dimensions.width * 0.92) / masonryColumns - 5;
setImageWidth(paddedWidth);
setImageHeight((paddedWidth / width) * height);
});
return (
<>
{imageWidth && imageHeight && (
<TouchableHighlight
onPress={() =>
navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() })
}
style={memeMasonryItemStyles.view}>
<Image
source={{ uri: meme.uri }}
style={[
memeMasonryItemStyles.image,
{ width: imageWidth, height: imageHeight },
]}
/>
</TouchableHighlight>
)}
</>
);
};
export default MemesMasonryItem;

View File

@@ -0,0 +1,75 @@
import React, { RefObject } from 'react';
import { Meme } from '../../../database';
import { FlashList, MasonryFlashList } from '@shopify/flash-list';
import { HelperText } from 'react-native-paper';
import {
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
} from 'react-native';
import { useSelector } from 'react-redux';
import styles from '../../../styles';
import { RootState } from '../../../state';
import { ORIENTATION, useDimensions } from '../../../contexts';
import { getFlashListItemHeight } from '../../../utilities';
import MemesMasonryItem from './memesMasonryItem';
const memeMasonryViewStyles = StyleSheet.create({
helperText: {
marginVertical: 10,
},
flashList: {
paddingBottom: 100,
// Needed to prevent fucky MasonryFlashList, see https://github.com/Shopify/flash-list/issues/876
paddingHorizontal: 0.1,
},
});
const MemesMasonryView = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
}) => {
const { orientation, dimensions } = useDimensions();
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
return (
<MasonryFlashList
ref={flashListRef}
data={memes}
estimatedItemSize={getFlashListItemHeight(masonryColumns)}
estimatedListSize={{
height: dimensions.height,
width: dimensions.width * 0.92,
}}
numColumns={masonryColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme }) => <MemesMasonryItem meme={meme} />}
contentContainerStyle={{
paddingTop:
flashListPadding +
dimensions.height *
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
...memeMasonryViewStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[memeMasonryViewStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/>
);
};
export default MemesMasonryView;

View File

@@ -1,51 +0,0 @@
import React, { useState } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { Meme } from '../../database';
import { ROUTE, RootStackParamList } from '../../types';
import { Card } from 'react-native-paper';
import { Image, StyleSheet } from 'react-native';
import { useDimensions } from '../../contexts';
const memeCardStyles = StyleSheet.create({
card: {
margin: 5,
},
});
const MemeCard = ({ meme }: { meme: Meme }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
const { dimensions } = useDimensions();
const [imageWidth, setImageWidth] = useState<number>();
const [imageHeight, setImageHeight] = useState<number>();
Image.getSize(meme.uri, (width, height) => {
const paddedWidth = (dimensions.width * 0.92) / 2 - 10;
setImageWidth(paddedWidth);
setImageHeight((paddedWidth / width) * height);
});
return (
<>
{imageWidth && imageHeight && (
<Card
onPress={() =>
navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() })
}
style={memeCardStyles.card}>
<Card.Cover
source={{ uri: meme.uri }}
style={{ width: imageWidth, height: imageHeight }}
/>
<Card.Title
title={meme.title}
titleVariant="titleSmall"
titleNumberOfLines={3}
/>
</Card>
)}
</>
);
};
export default MemeCard;

View File

@@ -5,11 +5,7 @@ import { useDimensions } from '../../contexts';
import LoadingView from '../loadingView'; import LoadingView from '../loadingView';
import { MemeTagSelector } from '.'; import { MemeTagSelector } from '.';
import { Tag } from '../../database'; import { Tag } from '../../database';
import { import { StringValidationResult, validateMemeTitle } from '../../utilities';
StringValidationResult,
validateMemeDescription,
validateMemeTitle,
} from '../../utilities';
const memeEditorStyles = { const memeEditorStyles = {
image: { image: {
@@ -28,16 +24,12 @@ const MemeEditor = ({
imageUri, imageUri,
memeTitle, memeTitle,
setMemeTitle, setMemeTitle,
memeDescription,
setMemeDescription,
memeTags, memeTags,
setMemeTags, setMemeTags,
}: { }: {
imageUri: string; imageUri: string;
memeTitle: StringValidationResult; memeTitle: StringValidationResult;
setMemeTitle: (name: StringValidationResult) => void; setMemeTitle: (name: StringValidationResult) => void;
memeDescription: StringValidationResult;
setMemeDescription: (description: StringValidationResult) => void;
memeTags: Map<string, Tag>; memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void; setMemeTags: (tags: Map<string, Tag>) => void;
}) => { }) => {
@@ -48,8 +40,12 @@ const MemeEditor = ({
Image.getSize(imageUri, (width, height) => { Image.getSize(imageUri, (width, height) => {
const paddedWidth = dimensions.width * 0.92; const paddedWidth = dimensions.width * 0.92;
const paddedHeight = Math.max(
Math.min((paddedWidth / width) * height, 500),
100,
);
setImageWidth(paddedWidth); setImageWidth(paddedWidth);
setImageHeight((paddedWidth / width) * height); setImageHeight(paddedHeight);
}); });
if (!imageWidth || !imageHeight) return <LoadingView />; if (!imageWidth || !imageHeight) return <LoadingView />;
@@ -76,24 +72,13 @@ const MemeEditor = ({
}, },
memeEditorStyles.image, memeEditorStyles.image,
]} ]}
resizeMode="contain"
/> />
<MemeTagSelector <MemeTagSelector
memeTags={memeTags} memeTags={memeTags}
setMemeTags={setMemeTags} setMemeTags={setMemeTags}
style={memeEditorStyles.memeTagSelector} style={memeEditorStyles.memeTagSelector}
/> />
<TextInput
mode="outlined"
label="Description"
multiline
numberOfLines={6}
value={memeDescription.raw}
style={memeEditorStyles.description}
onChangeText={description =>
setMemeDescription(validateMemeDescription(description))
}
error={!memeDescription.valid}
/>
</> </>
); );
}; };

View File

@@ -24,9 +24,8 @@ class Meme extends Object<Meme> {
uri!: string; uri!: string;
size!: number; size!: number;
title!: string; title!: string;
description!: string;
isFavorite!: boolean; isFavorite!: boolean;
tags!: Tag[] | Realm.Set<Tag>; tags!: Realm.List<Tag>;
tagsLength!: number; tagsLength!: number;
dateCreated!: Date; dateCreated!: Date;
dateModified!: Date; dateModified!: Date;
@@ -42,9 +41,8 @@ class Meme extends Object<Meme> {
uri: 'string', uri: 'string',
size: 'int', size: 'int',
title: 'string', title: 'string',
description: { type: 'string', default: '' },
isFavorite: { type: 'bool', indexed: true, default: false }, isFavorite: { type: 'bool', indexed: true, default: false },
tags: { type: 'set', objectType: 'Tag', default: [] }, tags: { type: 'list', objectType: 'Tag', default: [] },
tagsLength: { type: 'int', default: 0 }, tagsLength: { type: 'int', default: 0 },
dateCreated: { type: 'date', default: () => new Date() }, dateCreated: { type: 'date', default: () => new Date() },
dateModified: { type: 'date', default: () => new Date() }, dateModified: { type: 'date', default: () => new Date() },

View File

@@ -7,7 +7,7 @@ class Tag extends Object<Tag> {
id!: BSON.UUID; id!: BSON.UUID;
name!: string; name!: string;
color!: string; color!: string;
memes!: Meme[] | Realm.Set<Meme>; memes!: Realm.List<Meme>;
memesLength!: number; memesLength!: number;
dateCreated!: Date; dateCreated!: Date;
dateModified!: Date; dateModified!: Date;
@@ -21,7 +21,7 @@ class Tag extends Object<Tag> {
id: { type: 'uuid', default: () => new BSON.UUID() }, id: { type: 'uuid', default: () => new BSON.UUID() },
name: { type: 'string', indexed: true }, name: { type: 'string', indexed: true },
color: { type: 'string', default: () => generateRandomColor() }, color: { type: 'string', default: () => generateRandomColor() },
memes: { type: 'set', objectType: 'Meme', default: [] }, memes: { type: 'list', objectType: 'Meme', default: [] },
memesLength: { type: 'int', default: 0 }, memesLength: { type: 'int', default: 0 },
dateCreated: { type: 'date', default: () => new Date() }, dateCreated: { type: 'date', default: () => new Date() },
dateModified: { type: 'date', default: () => new Date() }, dateModified: { type: 'date', default: () => new Date() },

View File

@@ -13,11 +13,7 @@ import styles from '../styles';
import { ROUTE, RootStackParamList } from '../types'; import { ROUTE, RootStackParamList } from '../types';
import { Meme, Tag } from '../database'; import { Meme, Tag } from '../database';
import { RootState } from '../state'; import { RootState } from '../state';
import { import { getMemeType, validateMemeTitle } from '../utilities';
getMemeType,
validateMemeDescription,
validateMemeTitle,
} from '../utilities';
import { MemeEditor } from '../components'; import { MemeEditor } from '../components';
const AddMeme = ({ const AddMeme = ({
@@ -35,9 +31,6 @@ const AddMeme = ({
const { file } = route.params; const { file } = route.params;
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme')); const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
const [memeDescription, setMemeDescription] = useState(
validateMemeDescription(''),
);
const [memeIsFavorite, setMemeIsFavorite] = useState(false); const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map<string, Tag>()); const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
@@ -69,7 +62,6 @@ const AddMeme = ({
uri, uri,
size, size,
title: memeTitle.parsed, title: memeTitle.parsed,
description: memeDescription.parsed,
isFavorite: memeIsFavorite, isFavorite: memeIsFavorite,
tags: [...memeTags.values()], tags: [...memeTags.values()],
tagsLength: memeTags.size, tagsLength: memeTags.size,
@@ -77,9 +69,8 @@ const AddMeme = ({
memeTags.forEach(tag => { memeTags.forEach(tag => {
tag.dateModified = new Date(); tag.dateModified = new Date();
const memes = tag.memes as Realm.Set<Meme>; tag.memes.push(meme);
memes.add(meme); tag.memesLength = tag.memes.length;
tag.memesLength = memes.size;
}); });
}); });
@@ -111,8 +102,6 @@ const AddMeme = ({
imageUri={file.uri} imageUri={file.uri}
memeTitle={memeTitle} memeTitle={memeTitle}
setMemeTitle={setMemeTitle} setMemeTitle={setMemeTitle}
memeDescription={memeDescription}
setMemeDescription={setMemeDescription}
memeTags={memeTags} memeTags={memeTags}
setMemeTags={setMemeTags} setMemeTags={setMemeTags}
/> />
@@ -122,7 +111,7 @@ const AddMeme = ({
mode="contained" mode="contained"
icon="floppy" icon="floppy"
onPress={handleSave} onPress={handleSave}
disabled={!memeTitle.valid || !memeDescription.valid || isSaving} disabled={!memeTitle.valid || isSaving}
loading={isSaving}> loading={isSaving}>
Save Save
</Button> </Button>

View File

@@ -10,7 +10,7 @@ import { ORIENTATION, useDimensions } from '../contexts';
import styles from '../styles'; import styles from '../styles';
import { RootStackParamList, ROUTE } from '../types'; import { RootStackParamList, ROUTE } from '../types';
import { Tag, Meme } from '../database'; import { Tag, Meme } from '../database';
import { validateMemeTitle, validateMemeDescription } from '../utilities'; import { validateMemeTitle } from '../utilities';
import { MemeEditor } from '../components'; import { MemeEditor } from '../components';
const EditMeme = ({ const EditMeme = ({
@@ -28,14 +28,8 @@ const EditMeme = ({
)!; )!;
const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title)); const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title));
const [memeDescription, setMemeDescription] = useState(
validateMemeDescription(meme.description),
);
const [memeIsFavorite, setMemeIsFavorite] = useState(meme.isFavorite);
const [memeTags, setMemeTags] = useState( const [memeTags, setMemeTags] = useState(
new Map<string, Tag>( new Map<string, Tag>(meme.tags.map(tag => [tag.id.toHexString(), tag])),
(meme.tags as Realm.Set<Tag>).map(tag => [tag.id.toHexString(), tag]),
),
); );
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -46,24 +40,22 @@ const EditMeme = ({
realm.write(() => { realm.write(() => {
meme.tags.forEach(tag => { meme.tags.forEach(tag => {
if (!memeTags.has(tag.id.toHexString())) { if (!memeTags.has(tag.id.toHexString())) {
const memes = tag.memes as Realm.Set<Meme>; tag.memes.slice(tag.memes.indexOf(meme), 1);
memes.delete(meme); tag.memesLength -= 1;
tag.memesLength = memes.size;
tag.dateModified = new Date(); tag.dateModified = new Date();
} }
}); });
memeTags.forEach(tag => { memeTags.forEach(tag => {
if (!(meme.tags as Realm.Set<Tag>).has(tag)) { if (!meme.tags.some(memeTag => memeTag.id.equals(tag.id))) {
const memes = tag.memes as Realm.Set<Meme>; tag.memes.push(meme);
memes.add(meme); tag.memesLength = tag.memes.length;
tag.memesLength = memes.size;
tag.dateModified = new Date(); tag.dateModified = new Date();
} }
}); });
meme.title = memeTitle.parsed; meme.title = memeTitle.parsed;
meme.description = memeDescription.parsed; // @ts-expect-error - Realm is a fuck
meme.tags = [...memeTags.values()]; meme.tags = [...memeTags.values()];
meme.tagsLength = memeTags.size; meme.tagsLength = memeTags.size;
meme.dateModified = new Date(); meme.dateModified = new Date();
@@ -74,9 +66,8 @@ const EditMeme = ({
const handleFavorite = () => { const handleFavorite = () => {
realm.write(() => { realm.write(() => {
meme.isFavorite = !memeIsFavorite; meme.isFavorite = !meme.isFavorite;
}); });
setMemeIsFavorite(!memeIsFavorite);
}; };
const handleDelete = async () => { const handleDelete = async () => {
@@ -86,9 +77,8 @@ const EditMeme = ({
realm.write(() => { realm.write(() => {
for (const tag of meme.tags) { for (const tag of meme.tags) {
tag.dateModified = new Date(); tag.dateModified = new Date();
const memes = tag.memes as Realm.Set<Meme>; tag.memes.slice(tag.memes.indexOf(meme), 1);
memes.delete(meme); tag.memesLength -= 1;
tag.memesLength = memes.size;
} }
realm.delete(meme); realm.delete(meme);
@@ -103,7 +93,7 @@ const EditMeme = ({
<Appbar.BackAction onPress={() => goBack()} /> <Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Edit Meme'} /> <Appbar.Content title={'Edit Meme'} />
<Appbar.Action <Appbar.Action
icon={memeIsFavorite ? 'heart' : 'heart-outline'} icon={meme.isFavorite ? 'heart' : 'heart-outline'}
onPress={handleFavorite} onPress={handleFavorite}
/> />
<Appbar.Action icon="delete" onPress={handleDelete} /> <Appbar.Action icon="delete" onPress={handleDelete} />
@@ -123,8 +113,6 @@ const EditMeme = ({
imageUri={meme.uri} imageUri={meme.uri}
memeTitle={memeTitle} memeTitle={memeTitle}
setMemeTitle={setMemeTitle} setMemeTitle={setMemeTitle}
memeDescription={memeDescription}
setMemeDescription={setMemeDescription}
memeTags={memeTags} memeTags={memeTags}
setMemeTags={setMemeTags} setMemeTags={setMemeTags}
/> />
@@ -134,7 +122,7 @@ const EditMeme = ({
mode="contained" mode="contained"
icon="floppy" icon="floppy"
onPress={handleSave} onPress={handleSave}
disabled={!memeTitle.valid || !memeDescription.valid || isSaving} disabled={!memeTitle.valid || isSaving}
loading={isSaving}> loading={isSaving}>
Save Save
</Button> </Button>

View File

@@ -43,9 +43,8 @@ const EditTag = ({
realm.write(() => { realm.write(() => {
for (const meme of tag.memes) { for (const meme of tag.memes) {
meme.dateModified = new Date(); meme.dateModified = new Date();
const tags = meme.tags as Realm.Set<Tag>; meme.tags.slice(meme.tags.indexOf(tag), 1);
tags.delete(tag); meme.tagsLength -= 1;
meme.tagsLength = tags.size;
} }
realm.delete(tag); realm.delete(tag);

View File

@@ -1,38 +1,79 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { RefObject, useCallback, useRef, useState } from 'react';
import { import {
BackHandler, BackHandler,
NativeScrollEvent, NativeScrollEvent,
NativeSyntheticEvent, NativeSyntheticEvent,
StyleSheet,
View, View,
} from 'react-native'; } from 'react-native';
import { useQuery } from '@realm/react'; import { useQuery } from '@realm/react';
import { useTheme, HelperText } from 'react-native-paper'; import { useTheme } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { FlashList, MasonryFlashList } from '@shopify/flash-list'; import { FlashList } from '@shopify/flash-list';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import styles from '../styles'; import styles from '../styles';
import { SORT_DIRECTION, memesSortQuery } from '../types'; import { SORT_DIRECTION, VIEW, memesSortQuery } from '../types';
import { RootState, setNavVisible } from '../state'; import { RootState, setNavVisible } from '../state';
import { Meme } from '../database'; import { Meme } from '../database';
import { ORIENTATION, useDimensions } from '../contexts'; import {
import { HideableHeader, MemesHeader } from '../components'; HideableHeader,
import MemeCard from '../components/memes/memeCard'; MemesHeader,
MemesMasonryView,
MemesGridView,
MemesListView,
} from '../components';
const memesStyles = StyleSheet.create({ const MemesView = ({
helperText: { memes,
marginVertical: 10, flashListRef,
}, flashListPadding,
flashList: { handleScroll,
paddingBottom: 100, }: {
// Needed to prevent fucky MasonryFlashList, see https://github.com/Shopify/flash-list/issues/876 memes: Realm.Results<Meme & Realm.Object<Meme>>;
paddingHorizontal: 0.01, 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 <></>;
}
}
};
const Memes = () => { const Memes = () => {
const { colors } = useTheme(); const { colors } = useTheme();
const { dimensions, orientation } = useDimensions();
const sort = useSelector((state: RootState) => state.memes.sort); const sort = useSelector((state: RootState) => state.memes.sort);
const sortDirection = useSelector( const sortDirection = useSelector(
(state: RootState) => state.memes.sortDirection, (state: RootState) => state.memes.sortDirection,
@@ -54,10 +95,31 @@ const Memes = () => {
collectionIn => { collectionIn => {
let collection = collectionIn; let collection = collectionIn;
const tokens = search
.match(/"[^"]+"|\S+/gi)
?.map(token => token.replaceAll(/["']/g, ''));
const tags = tokens
?.filter(token => token.startsWith('#'))
.map(tag => tag.slice(1));
const words = tokens?.filter(token => !token.startsWith('#'));
const tagsQuery = tags
?.map((tag, index) => `ANY tags.name CONTAINS[c] $${index}`)
.join(' OR ');
const wordsQuery = words
?.map((word, index) => `title CONTAINS[c] $${index}`)
.join(' OR ');
if (favoritesOnly) collection = collection.filtered('isFavorite == true'); if (favoritesOnly) collection = collection.filtered('isFavorite == true');
if (filter) collection = collection.filtered('type == $0', filter); if (filter) collection = collection.filtered('type == $0', filter);
if (search) { if (tags && tagsQuery) {
collection = collection.filtered('title CONTAINS[c] $0', search); collection = collection.filtered(tagsQuery, ...tags);
}
if (words && wordsQuery) {
collection = collection.filtered(wordsQuery, ...words);
} }
collection = collection.sorted( collection = collection.sorted(
@@ -79,7 +141,9 @@ const Memes = () => {
dispatch(setNavVisible(true)); dispatch(setNavVisible(true));
} else { } else {
const diff = currentOffset - scrollOffset; const diff = currentOffset - scrollOffset;
if (Math.abs(diff) > 50) dispatch(setNavVisible(diff < 0)); if (Math.abs(diff) > 50) {
dispatch(setNavVisible(diff < 0));
}
} }
setScrollOffset(currentOffset); setScrollOffset(currentOffset);
@@ -89,7 +153,6 @@ const Memes = () => {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
dispatch(setNavVisible(true));
const handleBackPress = () => { const handleBackPress = () => {
if (scrollOffset > 0) { if (scrollOffset > 0) {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
@@ -102,7 +165,13 @@ const Memes = () => {
return () => return () =>
BackHandler.removeEventListener('hardwareBackPress', handleBackPress); BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
}, [dispatch, scrollOffset]), }, [scrollOffset]),
);
useFocusEffect(
useCallback(() => {
dispatch(setNavVisible(true));
}, [dispatch]),
); );
return ( return (
@@ -121,32 +190,11 @@ const Memes = () => {
}} }}
/> />
</HideableHeader> </HideableHeader>
<MasonryFlashList <MemesView
ref={flashListRef} memes={memes}
data={memes} flashListRef={flashListRef}
estimatedItemSize={200} flashListPadding={flashListPadding}
estimatedListSize={{ handleScroll={handleScroll}
height: dimensions.height,
width: dimensions.width * 0.92,
}}
numColumns={2}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme }) => <MemeCard meme={meme} />}
contentContainerStyle={{
paddingTop:
flashListPadding +
dimensions.height *
(orientation === ORIENTATION.PORTRAIT ? 0.02 : 0.04),
...memesStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText
type={'info'}
style={[memesStyles.helperText, styles.centerText]}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
/> />
</View> </View>
); );

View File

@@ -4,6 +4,7 @@ import {
Button, Button,
List, List,
Portal, Portal,
SegmentedButtons,
Snackbar, Snackbar,
Switch, Switch,
Text, Text,
@@ -13,19 +14,34 @@ import { openDocumentTree } from 'react-native-scoped-storage';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import type {} from 'redux-thunk/extend-redux'; import type {} from 'redux-thunk/extend-redux';
import styles from '../styles'; import styles from '../styles';
import { RootState, setNoMedia, setStorageUri } from '../state'; import {
RootState,
setGridColumns,
setMasonryColumns,
setNoMedia,
setStorageUri,
} from '../state';
import { ORIENTATION, useDimensions } from '../contexts'; import { ORIENTATION, useDimensions } from '../contexts';
const settingsScreenStyles = StyleSheet.create({ const settingsStyles = StyleSheet.create({
snackbar: { snackbar: {
marginBottom: 90, marginBottom: 90,
}, },
marginBottom: {
marginBottom: 15,
},
}); });
const SettingsScreen = () => { const Settings = () => {
const { colors } = useTheme(); const { colors } = useTheme();
const { orientation, responsive } = useDimensions(); const { orientation } = useDimensions();
const noMedia = useSelector((state: RootState) => state.settings.noMedia); const noMedia = useSelector((state: RootState) => state.settings.noMedia);
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isOptimizingDatabase, setIsOptimizingDatabase] = useState(false); const [isOptimizingDatabase, setIsOptimizingDatabase] = useState(false);
@@ -52,21 +68,56 @@ const SettingsScreen = () => {
]}> ]}>
<View> <View>
<List.Section> <List.Section>
<List.Subheader>Database</List.Subheader> <List.Subheader>Views</List.Subheader>
<Button <Text
mode="elevated" style={[
loading={isOptimizingDatabase} settingsStyles.marginBottom,
onPress={optimizeDatabase}> styles.smallPaddingHorizontal,
Optimize Database Now ]}>
</Button> Masonry Columns
</Text>
<SegmentedButtons
value={masonryColumns.toString()}
onValueChange={value => {
void dispatch(
setMasonryColumns(Number.parseInt(value) as 1 | 2 | 3 | 4),
);
}}
buttons={[
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
]}
style={settingsStyles.marginBottom}
/>
<Text
style={[
settingsStyles.marginBottom,
styles.smallPaddingHorizontal,
]}>
Grid Columns
</Text>
<SegmentedButtons
value={gridColumns.toString()}
onValueChange={value => {
void dispatch(
setGridColumns(Number.parseInt(value) as 1 | 2 | 3 | 4),
);
}}
buttons={[
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
]}
/>
</List.Section> </List.Section>
<List.Section> <List.Section>
<List.Subheader>Media Storage</List.Subheader> <List.Subheader>Media Storage</List.Subheader>
<Button <Button
mode="elevated" mode="elevated"
style={{ style={settingsStyles.marginBottom}
marginBottom: responsive.verticalScale(15),
}}
onPress={async () => { onPress={async () => {
const { uri } = await openDocumentTree(true); const { uri } = await openDocumentTree(true);
void dispatch(setStorageUri(uri)); void dispatch(setStorageUri(uri));
@@ -77,9 +128,6 @@ const SettingsScreen = () => {
style={[ style={[
styles.flexRowSpaceBetween, styles.flexRowSpaceBetween,
styles.smallPaddingHorizontal, styles.smallPaddingHorizontal,
{
marginBottom: responsive.verticalScale(15),
},
]}> ]}>
<Text>Hide media from gallery</Text> <Text>Hide media from gallery</Text>
<Switch <Switch
@@ -91,12 +139,21 @@ const SettingsScreen = () => {
</View> </View>
</List.Section> </List.Section>
</View> </View>
<List.Section>
<List.Subheader>Database</List.Subheader>
<Button
mode="elevated"
loading={isOptimizingDatabase}
onPress={optimizeDatabase}>
Optimize Database Now
</Button>
</List.Section>
</ScrollView> </ScrollView>
<Portal> <Portal>
<Snackbar <Snackbar
visible={snackbarVisible} visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)} onDismiss={() => setSnackbarVisible(false)}
style={settingsScreenStyles.snackbar} style={settingsStyles.snackbar}
action={{ action={{
label: 'Dismiss', label: 'Dismiss',
onPress: () => setSnackbarVisible(false), onPress: () => setSnackbarVisible(false),
@@ -108,4 +165,4 @@ const SettingsScreen = () => {
); );
}; };
export default SettingsScreen; export default Settings;

View File

@@ -80,7 +80,6 @@ const Tags = () => {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
dispatch(setNavVisible(true));
const handleBackPress = () => { const handleBackPress = () => {
if (scrollOffset > 0) { if (scrollOffset > 0) {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
@@ -93,7 +92,13 @@ const Tags = () => {
return () => return () =>
BackHandler.removeEventListener('hardwareBackPress', handleBackPress); BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
}, [dispatch, scrollOffset]), }, [scrollOffset]),
);
useFocusEffect(
useCallback(() => {
dispatch(setNavVisible(true));
}, [dispatch]),
); );
return ( return (

View File

@@ -54,6 +54,8 @@ export {
type SettingsState, type SettingsState,
setStorageUri, setStorageUri,
setNoMedia, setNoMedia,
setMasonryColumns,
setGridColumns,
validateSettings, validateSettings,
} from './settings'; } from './settings';
export { export {

View File

@@ -37,6 +37,10 @@ const memesSlice = createSlice({
cycleMemesView: state => { cycleMemesView: state => {
switch (state.view) { switch (state.view) {
case VIEW.MASONRY: { case VIEW.MASONRY: {
state.view = VIEW.GRID;
break;
}
case VIEW.GRID: {
state.view = VIEW.LIST; state.view = VIEW.LIST;
break; break;
} }

View File

@@ -11,11 +11,15 @@ import { RootState } from '.';
interface SettingsState { interface SettingsState {
storageUri: string | undefined; storageUri: string | undefined;
noMedia: boolean; noMedia: boolean;
masonryColumns: 1 | 2 | 3 | 4;
gridColumns: 1 | 2 | 3 | 4;
} }
const initialState: SettingsState = { const initialState: SettingsState = {
storageUri: undefined, storageUri: undefined,
noMedia: false, noMedia: false,
masonryColumns: 2,
gridColumns: 3,
}; };
const settingsSlice = createSlice({ const settingsSlice = createSlice({
@@ -28,10 +32,17 @@ const settingsSlice = createSlice({
setNoMedia: (state, action: PayloadAction<boolean>) => { setNoMedia: (state, action: PayloadAction<boolean>) => {
state.noMedia = action.payload; state.noMedia = action.payload;
}, },
setMasonryColumns: (state, action: PayloadAction<1 | 2 | 3 | 4>) => {
state.masonryColumns = action.payload;
},
setGridColumns: (state, action: PayloadAction<1 | 2 | 3 | 4>) => {
state.gridColumns = action.payload;
},
}, },
}); });
const { setStorageUri, setNoMedia } = settingsSlice.actions; const { setStorageUri, setNoMedia, setMasonryColumns, setGridColumns } =
settingsSlice.actions;
const updateStorageUri = createAsyncThunk( const updateStorageUri = createAsyncThunk(
'settings/updateStorageUri', 'settings/updateStorageUri',
@@ -111,6 +122,8 @@ export {
type SettingsState, type SettingsState,
updateStorageUri as setStorageUri, updateStorageUri as setStorageUri,
updateNoMedia as setNoMedia, updateNoMedia as setNoMedia,
setMasonryColumns,
setGridColumns,
validateSettings, validateSettings,
}; };
export default settingsSlice.reducer; export default settingsSlice.reducer;

View File

@@ -1,5 +1,6 @@
enum VIEW { enum VIEW {
MASONRY = 'Masonry', MASONRY = 'Masonry',
GRID = 'Grid',
LIST = 'List', LIST = 'List',
} }

View File

@@ -0,0 +1,9 @@
const getFlashListItemHeight = (numColumns: number) => {
const A = 500;
const B = 300;
const C = 1;
const height = A - B * Math.log(numColumns + C);
return Math.max(Math.round(height), 0);
};
export { getFlashListItemHeight };

View File

@@ -50,6 +50,9 @@ const getViewIcon = (view: VIEW) => {
case VIEW.MASONRY: { case VIEW.MASONRY: {
return 'view-dashboard'; return 'view-dashboard';
} }
case VIEW.GRID: {
return 'view-grid';
}
case VIEW.LIST: { case VIEW.LIST: {
return 'view-list'; return 'view-list';
} }

View File

@@ -7,6 +7,7 @@ export {
} from './color'; } from './color';
export { packageName, appName, fileProvider, noOp } from './constants'; export { packageName, appName, fileProvider, noOp } from './constants';
export { multipleIdQuery } from './database'; export { multipleIdQuery } from './database';
export { getFlashListItemHeight } from './dimensions';
export { export {
allowedImageMimeTypes, allowedImageMimeTypes,
allowedGifMimeTypes, allowedGifMimeTypes,
@@ -18,7 +19,6 @@ export { getSortIcon, getViewIcon } from './icon';
export { export {
type StringValidationResult, type StringValidationResult,
validateMemeTitle, validateMemeTitle,
validateMemeDescription,
validateTagName, validateTagName,
validateColor, validateColor,
} from './validation'; } from './validation';

View File

@@ -26,18 +26,6 @@ const validateMemeTitle = (title: string): StringValidationResult => {
}; };
}; };
const validateMemeDescription = (
description: string,
): StringValidationResult => {
const parsedDescription = description.trim();
return {
valid: true,
raw: description,
parsed: parsedDescription,
};
};
const validateTagName = (name: string): StringValidationResult => { const validateTagName = (name: string): StringValidationResult => {
const parsedName = name.trim(); const parsedName = name.trim();
@@ -79,7 +67,6 @@ const validateColor = (color: string): StringValidationResult => {
export { export {
type StringValidationResult, type StringValidationResult,
validateMemeTitle, validateMemeTitle,
validateMemeDescription,
validateTagName, validateTagName,
validateColor, validateColor,
}; };