Reorganize files

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-08-01 14:53:10 +03:00
parent b83407f1f4
commit d2054b028a
33 changed files with 90 additions and 103 deletions

View File

@@ -15,17 +15,17 @@ import {
ROUTE,
RootStackParamList,
StagingMeme,
} from '../../types';
import { Meme, Tag } from '../../database';
import { RootState } from '../../state';
} from '../../../types';
import { Meme, Tag } from '../../../database';
import { RootState } from '../../../state';
import {
allowedMimeTypes,
getMemeTypeFromMimeType,
guessMimeType,
validateMemeTitle,
} from '../../utilities';
import { MemeEditor } from '../../components';
import editorStyles from './editorStyles';
} from '../../../utilities';
import MemeEditor from './memeEditor';
import editorStyles from '../editorStyles';
const AddMeme = ({
route,

View File

@@ -6,12 +6,12 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { BSON } from 'realm';
import { RootStackParamList, ROUTE, StagingMeme } from '../../types';
import { pickSingle } from 'react-native-document-picker';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { extension } from 'react-native-mime-types';
import { useSelector } from 'react-redux';
import { Meme } from '../../database';
import { RootStackParamList, ROUTE, StagingMeme } from '../../../types';
import { Meme } from '../../../database';
import {
allowedMimeTypes,
deleteMeme,
@@ -20,10 +20,10 @@ import {
guessMimeType,
noOp,
validateMemeTitle,
} from '../../utilities';
import { MemeEditor } from '../../components';
import editorStyles from './editorStyles';
import { RootState } from '../../state';
} from '../../../utilities';
import { RootState } from '../../../state';
import MemeEditor from './memeEditor';
import editorStyles from '../editorStyles';
const EditMeme = ({
route,

View File

@@ -0,0 +1,159 @@
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 Video from 'react-native-video';
import { LoadingView, MemeFail } from '../../../components';
import {
getFilenameFromUri,
getMemeTypeFromMimeType,
validateMemeTitle,
} from '../../../utilities';
import { StagingMeme } from '../../../types';
import { useMemeDimensions } from '../../../hooks';
import { MEME_TYPE } from '../../../database';
import MemeTagSelector from './memeTagSelector/memeTagSelector';
const memeEditorStyles = {
media: {
marginBottom: 15,
borderRadius: 5,
},
uri: {
marginBottom: 15,
marginHorizontal: 5,
},
memeTagSelector: {
marginBottom: 10,
},
description: {
marginBottom: 10,
},
};
const MemeEditor = ({
uri,
mimeType,
loading,
setLoading,
error,
setError,
staging,
setStaging,
}: {
uri?: string;
mimeType?: string;
loading: boolean;
setLoading: (loading: boolean) => void;
error: Error | undefined;
setError: (error: Error | undefined) => void;
staging?: StagingMeme;
setStaging: (staging: StagingMeme) => void;
}) => {
const { width } = useSafeAreaFrame();
const { colors } = useTheme();
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 (
<Image
source={{ uri }}
style={[memeEditorStyles.media, dimensionStyles]}
resizeMode="contain"
/>
);
}
case MEME_TYPE.VIDEO: {
return (
<Video
source={{ uri }}
style={[memeEditorStyles.media, dimensionStyles]}
resizeMode="contain"
controls
/>
);
}
default: {
return <></>;
}
}
}, [dimensions, mimeType, uri, width]);
if (!uri || !mimeType || !staging) return <LoadingView />;
return (
<>
<TextInput
mode="outlined"
label="Title"
value={staging.title.raw}
onChangeText={title =>
setStaging({ ...staging, title: validateMemeTitle(title) })
}
error={!staging.title.valid}
selectTextOnFocus
/>
<HelperText type="error" visible={!staging.title.valid}>
{staging.title.error}
</HelperText>
{error ? (
<MemeFail
style={[
{
width: width * 0.92,
height: width * 0.92,
},
memeEditorStyles.media,
]}
iconSize={50}
/>
) : // eslint-disable-next-line unicorn/no-nested-ternary
loading || !dimensions ? (
<></>
) : (
mediaComponent
)}
<Text
variant="bodySmall"
style={[memeEditorStyles.uri, { color: colors.onSurfaceDisabled }]}
numberOfLines={1}>
{getFilenameFromUri(uri)}
</Text>
<MemeTagSelector
memeTags={staging.tags}
setMemeTags={tags => setStaging({ ...staging, tags })}
style={memeEditorStyles.memeTagSelector}
/>
</>
);
};
export default MemeEditor;

View File

@@ -0,0 +1,168 @@
import React, { useRef, useState } from 'react';
import { useQuery, useRealm } from '@realm/react';
import { Chip, Modal, Portal, Searchbar, useTheme } from 'react-native-paper';
import { LayoutAnimation, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { TAG_SORT, tagSortQuery } from '../../../../types';
import { TagChip } from '../../../../components';
import { Tag } from '../../../../database';
import { validateTagName } from '../../../../utilities';
const memeTagSearchModalStyles = StyleSheet.create({
modal: {
position: 'absolute',
bottom: 0,
padding: 10,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
width: '100%',
},
searchbar: {
marginBottom: 12,
},
tagChip: {
marginRight: 8,
},
});
const tagLayoutAnimation = {
duration: 150,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
update: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
delete: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
};
const MemeTagSearchModal = ({
visible,
setVisible,
memeTags,
setMemeTags,
}: {
visible: boolean;
setVisible: (visible: boolean) => void;
memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void;
}) => {
const { width } = useSafeAreaFrame();
const { colors } = useTheme();
const realm = useRealm();
const flashListRef = useRef<FlashList<Tag>>(null);
const [search, setSearch] = useState('');
const [tagName, setTagName] = useState(validateTagName(search));
const handleSearch = (newSearch: string) => {
flashListRef.current?.scrollToOffset({ offset: 0 });
setSearch(newSearch);
setTagName(validateTagName(newSearch));
};
const tags = useQuery<Tag>(
Tag.schema.name,
collectionIn => {
let collection = collectionIn;
if (search) {
collection = collection.filtered('name CONTAINS[c] $0', tagName.parsed);
}
collection = collection.sorted(
tagSortQuery(TAG_SORT.DATE_MODIFIED),
true,
);
return collection;
},
[search],
);
const handleTagPress = (tag: Tag) => {
const id = tag.id.toHexString();
memeTags.delete(id) || memeTags.set(id, tag);
setMemeTags(new Map(memeTags));
flashListRef.current?.prepareForLayoutAnimationRender();
LayoutAnimation.configureNext(tagLayoutAnimation);
};
const handleCreateTag = (name: string) => {
let tag: Tag | undefined;
realm.write(() => {
tag = realm.create<Tag>(Tag.schema.name, {
name,
});
});
if (!tag) return;
memeTags.set(tag.id.toHexString(), tag);
setMemeTags(new Map(memeTags));
flashListRef.current?.prepareForLayoutAnimationRender();
LayoutAnimation.configureNext(tagLayoutAnimation);
};
return (
<Portal>
<Modal
visible={visible}
contentContainerStyle={[
memeTagSearchModalStyles.modal,
{
backgroundColor: colors.surface,
},
]}
onDismiss={() => setVisible(false)}>
<Searchbar
placeholder="Search or Create Tags"
onChangeText={handleSearch}
value={search}
style={memeTagSearchModalStyles.searchbar}
autoFocus
/>
<FlashList
ref={flashListRef}
data={tags}
extraData={memeTags}
keyExtractor={tag => tag.id.toHexString()}
horizontal
estimatedItemSize={120}
estimatedListSize={{
width: width - 10,
height: 34.5,
}}
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps={'always'}
renderItem={({ item: tag }) => (
<TagChip
tag={tag}
style={memeTagSearchModalStyles.tagChip}
onPress={() => handleTagPress(tag)}
active={memeTags.has(tag.id.toHexString())}
/>
)}
ListEmptyComponent={() => (
<Chip
icon="plus"
mode="outlined"
onPress={() =>
handleCreateTag(tagName.valid ? tagName.parsed : 'newTag')
}>
Create Tag #{tagName.valid ? tagName.parsed : 'newTag'}
</Chip>
)}
fadingEdgeLength={50}
/>
</Modal>
</Portal>
);
};
export default MemeTagSearchModal;

View File

@@ -0,0 +1,101 @@
import React, { ComponentProps, useRef, useState } from 'react';
import { LayoutAnimation, StyleSheet, View } from 'react-native';
import { Chip } from 'react-native-paper';
import { FlashList } from '@shopify/flash-list';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { TagChip } from '../../../../components';
import { Tag } from '../../../../database';
import MemeTagSearchModal from './memeTagSearchModal';
const memeTagSelectorStyles = StyleSheet.create({
tagChip: {
marginRight: 8,
},
});
const tagLayoutAnimation = {
duration: 150,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
update: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
delete: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
};
const MemeTagSelector = ({
memeTags,
setMemeTags,
...props
}: {
memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void;
} & ComponentProps<typeof View>) => {
const { width } = useSafeAreaFrame();
const flashListRef = useRef<FlashList<Tag>>(null);
const [flashListMargin, setFlashListMargin] = useState(0);
const [tagSearchModalVisible, setTagSearchModalVisible] = useState(false);
const handleTagPress = (tag: Tag) => {
const id = tag.id.toHexString();
memeTags.delete(id);
setMemeTags(new Map(memeTags));
flashListRef.current?.prepareForLayoutAnimationRender();
LayoutAnimation.configureNext(tagLayoutAnimation);
};
return (
<>
<View {...props}>
<FlashList
ref={flashListRef}
data={[...memeTags.values()]}
keyExtractor={tag => tag.id.toHexString()}
horizontal
estimatedItemSize={120}
showsHorizontalScrollIndicator={false}
renderItem={({ item: tag }) => (
<TagChip
tag={tag}
onPress={() => handleTagPress(tag)}
style={memeTagSelectorStyles.tagChip}
/>
)}
ListFooterComponent={() => (
<Chip
icon="plus"
mode="outlined"
onPress={() => setTagSearchModalVisible(true)}
onLayout={event =>
setFlashListMargin(
width * 0.92 - event.nativeEvent.layout.width,
)
}
style={{
marginRight: flashListMargin,
}}>
Add Tag
</Chip>
)}
fadingEdgeLength={50}
/>
</View>
<MemeTagSearchModal
visible={tagSearchModalVisible}
setVisible={setTagSearchModalVisible}
memeTags={memeTags}
setMemeTags={setMemeTags}
/>
</>
);
};
export default MemeTagSelector;

View File

@@ -8,11 +8,11 @@ import {
generateRandomColor,
validateColor,
validateTagName,
} from '../../utilities';
import { Tag } from '../../database';
import { TagEditor } from '../../components';
import editorStyles from './editorStyles';
import { StagingTag } from '../../types';
} from '../../../utilities';
import { Tag } from '../../../database';
import { StagingTag } from '../../../types';
import TagEditor from './tagEditor';
import editorStyles from '../editorStyles';
const AddTag = () => {
const { goBack } = useNavigation();

View File

@@ -6,11 +6,11 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BSON } from 'realm';
import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { TagEditor } from '../../components';
import { ROUTE, RootStackParamList, StagingTag } from '../../types';
import { Tag } from '../../database';
import { deleteTag, validateColor, validateTagName } from '../../utilities';
import editorStyles from './editorStyles';
import { ROUTE, RootStackParamList, StagingTag } from '../../../types';
import { Tag } from '../../../database';
import { deleteTag, validateColor, validateTagName } from '../../../utilities';
import TagEditor from './tagEditor';
import editorStyles from '../editorStyles';
const EditTag = ({
route,

View File

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

View File

@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { Chip, useTheme } from 'react-native-paper';
import { getContrastColor } from '../../../utilities';
const tagPreviewStyles = StyleSheet.create({
view: {
flexDirection: 'row',
justifyContent: 'center',
margin: '10%',
},
chip: {
padding: 5,
},
text: {
fontSize: 18,
},
});
const TagPreview = ({ name, color }: { name: string; color: string }) => {
const theme = useTheme();
const chipTheme = useMemo(() => {
return {
...theme,
colors: {
...theme.colors,
secondaryContainer: color,
},
};
}, [theme, color]);
const contrastColor = getContrastColor(color);
return (
<View style={tagPreviewStyles.view}>
<Chip
icon={() => {
return <FontAwesome5 name="tag" size={14} color={contrastColor} />;
}}
elevated
style={tagPreviewStyles.chip}
theme={chipTheme}
textStyle={[tagPreviewStyles.text, { color: contrastColor }]}>
{'#' + name}
</Chip>
</View>
);
};
export default TagPreview;

View File

@@ -1,9 +1,9 @@
export { default as AddMeme } from './editors/addMeme';
export { default as AddTag } from './editors/addTag';
export { default as EditMeme } from './editors/editMeme';
export { default as EditTag } from './editors/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 AddMeme } from './editors/meme/addMeme';
export { default as AddTag } from './editors/tag/addTag';
export { default as EditMeme } from './editors/meme/editMeme';
export { default as EditTag } from './editors/tag/editTag';
export { default as Memes } from './memes/memes';
export { default as MemeView } from './memeView/memeView';
export { default as Settings } from './settings/settings';
export { default as Tags } from './tags/tags';
export { default as Welcome } from './welcome';

View File

@@ -12,9 +12,9 @@ import {
RootStackParamList,
ROUTE,
SORT_DIRECTION,
} from '../types';
import { Meme } from '../database';
import { LoadingView, MemeViewItem } from '../components';
} from '../../types';
import { Meme } from '../../database';
import { LoadingView } from '../../components';
import {
copyMeme,
deleteMeme,
@@ -22,8 +22,9 @@ import {
favoriteMeme,
multipleIdQuery,
shareMeme,
} from '../utilities';
import { RootState } from '../state';
} from '../../utilities';
import { RootState } from '../../state';
import MemeViewItem from './memeViewItem';
const memeViewStyles = StyleSheet.create({
// eslint-disable-next-line react-native/no-color-literals

View File

@@ -0,0 +1,78 @@
import React, { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { AndroidScoped } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import Video from 'react-native-video';
import { MEME_TYPE, Meme } from '../../database';
import { RootState } from '../../state';
import { AnimatedImage, LoadingView, MemeFail } from '../../components';
import { useMemeDimensions } from '../../hooks';
const memeViewItemStyles = StyleSheet.create({
view: {
justifyContent: 'center',
alignItems: 'center',
},
});
const MemeViewItem = ({ meme }: { meme: Meme }) => {
const { height, width } = useSafeAreaFrame();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
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 <AnimatedImage source={{ uri }} style={dimensionStyles} />;
}
default: {
return (
<Video source={{ uri }} style={dimensionStyles} paused controls />
);
}
}
}, [dimensions, height, meme.memeType, uri, width]);
if (!error && (loading || !dimensions)) {
return <LoadingView style={{ width, height }} />;
}
return (
<View style={[{ width, height }, memeViewItemStyles.view]}>
{error || !dimensions ? (
<MemeFail
style={{
width: Math.min(width, height - 128),
height: Math.min(width, height - 128),
}}
iconSize={50}
/>
) : (
mediaComponent
)}
</View>
);
};
export default MemeViewItem;

View File

@@ -17,11 +17,13 @@ import {
useNavigation,
} from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../types';
import { RootState, setNavVisible } from '../state';
import { Meme } from '../database';
import { HideableHeader, MemesHeader, MemesList } from '../components';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../../types';
import { RootState, setNavVisible } from '../../state';
import { Meme } from '../../database';
import { HideableHeader } from '../../components';
import MemesHeader from './memesHeader';
import MemesList from './memesList/memesList';
const memesStyles = StyleSheet.create({
listView: {

View File

@@ -0,0 +1,174 @@
import React, { ComponentProps, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import {
Button,
Divider,
IconButton,
Menu,
Searchbar,
useTheme,
} from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import { MEME_TYPE, memeTypePlural } from '../../database';
import {
RootState,
cycleMemesView,
setMemesFilter,
setMemesSort,
setMemesSortDirection,
toggleMemesFavoritesOnly,
toggleMemesSortDirection,
} from '../../state';
import { MEME_SORT, SORT_DIRECTION } from '../../types';
import { getSortIcon, getViewIcon } from '../../utilities';
const memesHeaderStyles = StyleSheet.create({
buttonView: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
height: 50,
},
buttonSection: {
flexDirection: 'row',
alignItems: 'center',
},
sortButton: {
flexDirection: 'row-reverse',
},
});
const MemesHeader = ({
search,
setSearch,
autoFocus,
...props
}: {
search: string;
setSearch: (search: string) => void;
autoFocus: boolean;
} & ComponentProps<typeof View>) => {
const { colors } = useTheme();
const sort = useSelector((state: RootState) => state.memes.sort);
const sortDirection = useSelector(
(state: RootState) => state.memes.sortDirection,
);
const view = useSelector((state: RootState) => state.memes.view);
const favoritesOnly = useSelector(
(state: RootState) => state.memes.favoritesOnly,
);
const filter = useSelector((state: RootState) => state.memes.filter);
const dispatch = useDispatch();
const [sortMenuVisible, setSortMenuVisible] = useState(false);
const [filterMenuVisible, setFilterMenuVisible] = useState(false);
const handleSortModeChange = (newSort: MEME_SORT) => {
if (newSort === sort) {
dispatch(toggleMemesSortDirection());
} else {
dispatch(setMemesSort(newSort));
if (newSort === MEME_SORT.TITLE) {
dispatch(setMemesSortDirection(SORT_DIRECTION.ASCENDING));
} else {
dispatch(setMemesSortDirection(SORT_DIRECTION.DESCENDING));
}
}
setSortMenuVisible(false);
};
const handleFilterChange = (newFilter: MEME_TYPE | undefined) => {
dispatch(setMemesFilter(newFilter));
setFilterMenuVisible(false);
};
return (
<View {...props}>
<Searchbar
placeholder="Search Memes"
value={search}
onChangeText={setSearch}
autoFocus={autoFocus}
/>
<View style={memesHeaderStyles.buttonView}>
<View style={memesHeaderStyles.buttonSection}>
<Menu
visible={sortMenuVisible}
onDismiss={() => setSortMenuVisible(false)}
anchor={
<Button
onPress={() => setSortMenuVisible(true)}
icon={getSortIcon(sort, sortDirection)}
contentStyle={memesHeaderStyles.sortButton}
compact>
Sort By: {sort}
</Button>
}>
{Object.keys(MEME_SORT).map(key => {
return (
<Menu.Item
key={key}
onPress={() =>
handleSortModeChange(
MEME_SORT[key as keyof typeof MEME_SORT],
)
}
title={MEME_SORT[key as keyof typeof MEME_SORT]}
/>
);
})}
</Menu>
</View>
<View style={memesHeaderStyles.buttonSection}>
<IconButton
icon={getViewIcon(view)}
iconColor={colors.primary}
size={16}
animated
onPress={() => dispatch(cycleMemesView())}
/>
<IconButton
icon={favoritesOnly ? 'heart' : 'heart-outline'}
iconColor={colors.primary}
size={16}
animated
onPress={() => dispatch(toggleMemesFavoritesOnly())}
/>
<Menu
visible={filterMenuVisible}
onDismiss={() => setFilterMenuVisible(false)}
anchor={
<IconButton
onPress={() => setFilterMenuVisible(true)}
icon={filter ? 'filter' : 'filter-outline'}
iconColor={colors.primary}
size={16}
/>
}>
<Menu.Item
// eslint-disable-next-line unicorn/no-useless-undefined
onPress={() => handleFilterChange(undefined)}
title="All"
/>
{Object.keys(MEME_TYPE).map(key => {
return (
<Menu.Item
key={key}
onPress={() =>
handleFilterChange(MEME_TYPE[key as keyof typeof MEME_TYPE])
}
title={
memeTypePlural[MEME_TYPE[key as keyof typeof MEME_TYPE]]
}
/>
);
})}
</Menu>
</View>
</View>
<Divider />
</View>
);
};
export default MemesHeader;

View File

@@ -0,0 +1,78 @@
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 { AndroidScoped } from 'react-native-file-access';
import { MEME_TYPE, Meme } from '../../../database';
import { RootState } from '../../../state';
import { MemeFail } from '../../../components';
import { getFontAwesome5IconSize } from '../../../utilities';
import { useMemeDimensions } from '../../../hooks';
const MemesGridItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { width } = useSafeAreaFrame();
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
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 (
<Image
source={{ uri }}
style={[
{
width: itemWidth,
height: itemWidth,
},
]}
/>
);
}
default: {
return <></>;
}
}
}, [itemWidth, meme.memeType, uri]);
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableHighlight onPress={() => focusMeme(index)}>
{error ? (
<MemeFail
style={{
width: (width * 0.92 - 5) / gridColumns,
height: (width * 0.92 - 5) / gridColumns,
}}
iconSize={getFontAwesome5IconSize(gridColumns)}
/>
) : (
mediaComponent
)}
</TouchableHighlight>
);
};
export default MemesGridItem;

View File

@@ -0,0 +1,162 @@
import React, { RefObject } from 'react';
import { FlashList, MasonryFlashList } from '@shopify/flash-list';
import {
NativeSyntheticEvent,
NativeScrollEvent,
StyleSheet,
} from 'react-native';
import { useSelector } from 'react-redux';
import { Divider, HelperText } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { VIEW } from '../../../types';
import { getFlashListItemHeight } from '../../../utilities';
import MemesMasonryItem from './memesMasonryItem';
import MemesGridItem from './memesGridItem';
import MemesListItem from './memesListItem';
const sharedMemesListStyles = StyleSheet.create({
flashList: {
paddingBottom: 100,
},
helperText: {
textAlign: 'center',
marginVertical: 15,
},
});
const memesMasonryListStyles = StyleSheet.create({
flashList: {
// Needed to prevent fucky MasonryFlashList, see https://github.com/Shopify/flash-list/issues/876
paddingHorizontal: 0.1,
},
});
const memesGridListStyles = StyleSheet.create({
flashList: {
paddingHorizontal: 2.5,
},
});
const memesListListStyles = StyleSheet.create({
flashList: {
paddingHorizontal: 5,
},
});
const MemesList = ({
memes,
flashListRef,
flashListPadding,
handleScroll,
focusMeme,
}: {
memes: Realm.Results<Meme & Realm.Object<Meme>>;
flashListRef: RefObject<FlashList<Meme>>;
flashListPadding: number;
handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
focusMeme: (index: number) => void;
}) => {
const { height, width } = useSafeAreaFrame();
const view = useSelector((state: RootState) => state.memes.view);
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
return (
<>
{view === VIEW.MASONRY && (
<MasonryFlashList
ref={flashListRef}
data={memes}
key={masonryColumns}
estimatedItemSize={getFlashListItemHeight(masonryColumns)}
estimatedListSize={{
height,
width: width * 0.92,
}}
numColumns={masonryColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesMasonryItem meme={meme} index={index} focusMeme={focusMeme} />
)}
contentContainerStyle={{
paddingTop: flashListPadding,
...sharedMemesListStyles.flashList,
...memesMasonryListStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText type={'info'} style={sharedMemesListStyles.helperText}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
fadingEdgeLength={100}
/>
)}
{view === VIEW.GRID && (
<FlashList
ref={flashListRef}
data={memes}
key={gridColumns}
estimatedItemSize={getFlashListItemHeight(gridColumns)}
estimatedListSize={{
height: height,
width: width * 0.92,
}}
numColumns={gridColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesGridItem meme={meme} index={index} focusMeme={focusMeme} />
)}
contentContainerStyle={{
paddingTop: flashListPadding,
...sharedMemesListStyles.flashList,
...memesGridListStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText type={'info'} style={sharedMemesListStyles.helperText}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
fadingEdgeLength={100}
/>
)}
{view === VIEW.LIST && (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={50}
estimatedListSize={{
height: height,
width: width * 0.92,
}}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesListItem meme={meme} index={index} focusMeme={focusMeme} />
)}
ItemSeparatorComponent={() => <Divider />}
contentContainerStyle={{
paddingTop: flashListPadding,
...sharedMemesListStyles.flashList,
...memesListListStyles.flashList,
}}
ListEmptyComponent={() => (
<HelperText type={'info'} style={sharedMemesListStyles.helperText}>
No memes found
</HelperText>
)}
onScroll={handleScroll}
fadingEdgeLength={100}
/>
)}
</>
);
};
export default MemesList;

View File

@@ -0,0 +1,116 @@
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 { AndroidScoped } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import { MEME_TYPE, Meme } from '../../../database';
import { MemeFail } from '../../../components';
import { RootState } from '../../../state';
import { useMemeDimensions } from '../../../hooks';
const memesListItemStyles = StyleSheet.create({
view: {
flexDirection: 'row',
paddingVertical: 10,
},
image: {
height: 75,
width: 75,
borderRadius: 5,
},
detailsView: {
flexDirection: 'column',
marginLeft: 10,
},
text: {
marginRight: 5,
marginBottom: 5,
},
tagsView: {
flexDirection: 'row',
flexWrap: 'wrap',
},
});
const MemesListItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { width } = useSafeAreaFrame();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
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 <Image source={{ uri }} style={[memesListItemStyles.image]} />;
}
default: {
return <></>;
}
}
}, [meme.memeType, uri]);
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableRipple
onPress={() => focusMeme(index)}
style={memesListItemStyles.view}>
<>
{error ? (
<MemeFail style={memesListItemStyles.image} />
) : (
mediaComponent
)}
<View
style={[
memesListItemStyles.detailsView,
{
width: width * 0.92 - 75 - 10,
},
]}>
<Text variant="titleMedium" style={memesListItemStyles.text}>
{meme.title}
</Text>
<Text variant="labelSmall" style={memesListItemStyles.text}>
{meme.dateModified.toLocaleDateString()} {meme.size / 1000}
KB
</Text>
<View style={memesListItemStyles.tagsView}>
{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,93 @@
import React, { useMemo } from 'react';
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
import { useSelector } from 'react-redux';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { AndroidScoped } from 'react-native-file-access';
import { MEME_TYPE, Meme } from '../../../database';
import { RootState } from '../../../state';
import { MemeFail } from '../../../components';
import { getFontAwesome5IconSize } from '../../../utilities';
import { useMemeDimensions } from '../../../hooks';
const memeMasonryItemStyles = StyleSheet.create({
view: {
margin: 2.5,
borderRadius: 5,
},
image: {
borderRadius: 5,
},
});
const MemesMasonryItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { width } = useSafeAreaFrame();
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
const itemWidth = (width * 0.92 - 5) / masonryColumns - 5;
const itemHeight =
((width * 0.92) / masonryColumns - 5) / (dimensions?.aspectRatio ?? 1);
const mediaComponent = useMemo(() => {
switch (meme.memeType) {
case MEME_TYPE.IMAGE:
case MEME_TYPE.GIF:
case MEME_TYPE.VIDEO: {
return (
<Image
source={{ uri }}
style={[
memeMasonryItemStyles.image,
{
width: itemWidth,
height: itemHeight,
},
]}
/>
);
}
default: {
return <></>;
}
}
}, [itemHeight, itemWidth, meme.memeType, uri]);
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableHighlight
onPress={() => focusMeme(index)}
style={memeMasonryItemStyles.view}>
{error || !dimensions ? (
<MemeFail
style={[
memeMasonryItemStyles.image,
{ width: itemWidth, height: itemHeight },
]}
iconSize={getFontAwesome5IconSize(masonryColumns)}
/>
) : (
mediaComponent
)}
</TouchableHighlight>
);
};
export default MemesMasonryItem;

View File

@@ -11,6 +11,8 @@ import {
} from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import type {} from 'redux-thunk/extend-redux';
import { useRealm } from '@realm/react';
import { FileSystem, FileStat } from 'react-native-file-access';
import {
RootState,
setAutofocusMemesSearch,
@@ -19,11 +21,9 @@ import {
setMasonryColumns,
setNoMedia,
setSnackbarMessage,
} from '../state';
import StorageLocationChangeDialog from '../components/storageLocationChangeDialog';
import { useRealm } from '@realm/react';
import { FileSystem, FileStat } from 'react-native-file-access';
import { Meme } from '../database';
} from '../../state';
import { Meme } from '../../database';
import StorageLocationChangeDialog from './storageLocationChangeDialog';
const settingsStyles = StyleSheet.create({
scrollView: {

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
import { Dialog, ProgressBar, Text } from 'react-native-paper';
import { openDocumentTree } from 'react-native-scoped-storage';
import { useDispatch, useSelector } from 'react-redux';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { RootState, setStorageUri } from '../../state';
import { clearPermissions, isPermissionForPath, noOp } from '../../utilities';
const storageLocationChangeDialogStyles = StyleSheet.create({
progressBar: {
marginVertical: 15,
},
});
const StorageLocationChangeDialog = ({
visible,
setVisible,
setSnackbarMessage,
}: {
visible: boolean;
setVisible: (visible: boolean) => void;
setSnackbarMessage: (message: string) => void;
}) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const dispatch = useDispatch();
const [progress, setProgress] = useState(0);
useEffect(() => {
const selectNewStorageUri = async () => {
const uri = await openDocumentTree(true).catch(noOp);
if (!uri) {
setVisible(false);
return;
}
const newStorageUri = uri.uri;
if (isPermissionForPath(storageUri, newStorageUri)) {
setSnackbarMessage('Folder already selected.');
setVisible(false);
return;
}
const files = await FileSystem.ls(storageUri);
let filesCopied = 0;
await Promise.all(
files.map(async file => {
const oldUri = AndroidScoped.appendPath(storageUri, file);
const newUri = AndroidScoped.appendPath(newStorageUri, file);
// You may be wondering, why cp and unlink instead of mv?
// That's because Android is a fuck and does not allow moving across different scoped storage paths.
await FileSystem.cp(oldUri, newUri).catch(noOp);
await FileSystem.unlink(oldUri).catch(noOp);
filesCopied++;
setProgress(filesCopied / files.length);
}),
);
await dispatch(setStorageUri(newStorageUri));
await clearPermissions([newStorageUri]);
setVisible(false);
setProgress(0);
};
if (visible) void selectNewStorageUri();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
return (
<Dialog
visible={visible}
onDismiss={() => setVisible(false)}
dismissable={false}
dismissableBackButton={false}>
<Dialog.Title>Change Storage Location</Dialog.Title>
<Dialog.Content>
<Text>Copying files. Do not close the app.</Text>
<ProgressBar
animatedValue={progress}
style={storageLocationChangeDialogStyles.progressBar}
/>
</Dialog.Content>
</Dialog>
);
};
export default StorageLocationChangeDialog;

View File

@@ -13,10 +13,12 @@ import { FlashList } from '@shopify/flash-list';
import { useFocusEffect } from '@react-navigation/native';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { HideableHeader, TagRow, TagsHeader } from '../components';
import { Tag } from '../database';
import { RootState, setNavVisible } from '../state';
import { SORT_DIRECTION, tagSortQuery } from '../types';
import { HideableHeader } from '../../components';
import { Tag } from '../../database';
import { RootState, setNavVisible } from '../../state';
import { SORT_DIRECTION, tagSortQuery } from '../../types';
import TagsHeader from './tagsHeader';
import TagRow from './tagsList/tagRow';
const tagsStyles = StyleSheet.create({
listView: {

View File

@@ -0,0 +1,98 @@
import React, { ComponentProps, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { Button, Divider, Menu, Searchbar } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import {
RootState,
setTagsSort,
setTagsSortDirection,
toggleTagsSortDirection,
} from '../../state';
import { SORT_DIRECTION, TAG_SORT } from '../../types';
import { getSortIcon } from '../../utilities';
const tagsHeaderStyles = StyleSheet.create({
buttonView: {
alignItems: 'center',
flexDirection: 'row',
height: 50,
},
sortButton: {
flexDirection: 'row-reverse',
},
});
const TagsHeader = ({
search,
setSearch,
autoFocus,
...props
}: {
search: string;
setSearch: (search: string) => void;
autoFocus: boolean;
} & ComponentProps<typeof View>) => {
const sort = useSelector((state: RootState) => state.tags.sort);
const sortDirection = useSelector(
(state: RootState) => state.tags.sortDirection,
);
const dispatch = useDispatch();
const [sortMenuVisible, setSortMenuVisible] = useState(false);
const handleSortModeChange = (newSort: TAG_SORT) => {
if (newSort === sort) {
dispatch(toggleTagsSortDirection());
} else {
dispatch(setTagsSort(newSort));
if (newSort === TAG_SORT.NAME) {
dispatch(setTagsSortDirection(SORT_DIRECTION.ASCENDING));
} else {
dispatch(setTagsSortDirection(SORT_DIRECTION.DESCENDING));
}
}
setSortMenuVisible(false);
};
return (
<View {...props}>
<Searchbar
placeholder="Search Tags"
value={search}
onChangeText={(value: string) => {
setSearch(value);
}}
autoFocus={autoFocus}
/>
<View style={tagsHeaderStyles.buttonView}>
<Menu
visible={sortMenuVisible}
onDismiss={() => setSortMenuVisible(false)}
anchor={
<Button
onPress={() => setSortMenuVisible(true)}
icon={getSortIcon(sort, sortDirection)}
contentStyle={tagsHeaderStyles.sortButton}
compact>
Sort By: {sort}
</Button>
}>
{Object.keys(TAG_SORT).map(key => {
return (
<Menu.Item
key={key}
onPress={() =>
handleSortModeChange(TAG_SORT[key as keyof typeof TAG_SORT])
}
title={TAG_SORT[key as keyof typeof TAG_SORT]}
/>
);
})}
</Menu>
</View>
<Divider />
</View>
);
};
export default TagsHeader;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { TouchableRipple, Text } from 'react-native-paper';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { Tag } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import { TagChip } from '../../../components';
const tagRowStyles = StyleSheet.create({
view: {
justifyContent: 'space-between',
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 15,
},
tagChip: {
flexShrink: 1,
maxWidth: '80%',
},
});
const TagRow = ({ tag }: { tag: Tag }) => {
const { navigate } = useNavigation<NavigationProp<RootStackParamList>>();
return (
<TouchableRipple
onPress={() => navigate(ROUTE.EDIT_TAG, { id: tag.id.toHexString() })}>
<View style={tagRowStyles.view}>
<TagChip tag={tag} style={tagRowStyles.tagChip} />
<Text>{tag.memesLength}</Text>
</View>
</TouchableRipple>
);
};
export default TagRow;