diff --git a/assets/icons.tsx b/assets/icons.tsx index 44728bb1..254e1eb9 100644 --- a/assets/icons.tsx +++ b/assets/icons.tsx @@ -4,10 +4,12 @@ import { SvgXml } from 'react-native-svg'; export type IconType = | 'home_outline' + | 'share_outline' | 'document_outline' | 'search_outline' | 'close_modal_button' | 'red_x' + | 'share_outline' | 'green_check' | 'hide_password' | 'grey_dot' @@ -21,6 +23,7 @@ export type IconType = const IconSvgs: Record = { home_outline: , + share_outline: , search_outline: , document_outline: , settings_gear: , @@ -110,7 +113,6 @@ const IconSvgs: Record = { `} /> ), - home_inactive: ( =6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", + "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz", + "integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.5.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-native-fontawesome": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-native-fontawesome/-/react-native-fontawesome-0.3.0.tgz", + "integrity": "sha512-wSfetdK4+b/pvPbM2v+bZ5hfNlwtk9l3QuJo59sbMrxJalfX7BuF2WsSIWMSxfWwSsbOtY4+TUs6uw/rE59NJA==", + "dependencies": { + "humps": "^2.0.1", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react-native": ">= 0.67", + "react-native-svg": ">= 11.x" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -3723,6 +3775,27 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@iconify/react": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-4.1.1.tgz", + "integrity": "sha512-jed14EjvKjee8mc0eoscGxlg7mSQRkwQG3iX3cPBCO7UlOjz0DtlvTqxqEcHUJGh+z1VJ31Yhu5B9PxfO0zbdg==", + "dev": true, + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true + }, "node_modules/@jest/create-cache-key-function": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", @@ -12522,6 +12595,11 @@ "node": ">=10.17.0" } }, + "node_modules/humps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", + "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==" + }, "node_modules/husky": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", @@ -14331,6 +14409,14 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkify-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", + "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -14686,6 +14772,11 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -17334,6 +17425,19 @@ "htmlparser2-without-node-native": "^3.9.2" } }, + "node_modules/react-native-hyperlink": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/react-native-hyperlink/-/react-native-hyperlink-0.0.22.tgz", + "integrity": "sha512-IVhG+aP2bAnllUEr08/+rGA3cbpzyl83PtOfe94higfWUR8E7rUGThj21tdhx8SWAyl+4XO1K864tA6ybVbMFA==", + "dependencies": { + "linkify-it": "^4.0.1", + "mdurl": "^1.0.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-ionicons": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/react-native-ionicons/-/react-native-ionicons-4.6.5.tgz", @@ -19755,6 +19859,11 @@ "node": "*" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "node_modules/uglify-es": { "version": "3.3.9", "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", diff --git a/package.json b/package.json index 45a2c09d..4c735952 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "@emotion/styled": "^11.11.0", "@expo-google-fonts/manrope": "^0.2.3", "@expo/vector-icons": "^13.0.0", + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-native-fontawesome": "^0.3.0", "@mui/icons-material": "^5.14.13", "@mui/material": "^5.14.13", "@mui/styled-engine-sc": "^6.0.0-alpha.1", @@ -38,6 +41,7 @@ "expo": "~49.0.11", "expo-constants": "~14.4.2", "expo-font": "~11.4.0", + "expo-image": "~1.3.5", "expo-linking": "~5.0.2", "expo-router": "^2.0.0", "expo-status-bar": "~1.6.0", @@ -52,6 +56,7 @@ "react-native-emojicon": "^1.0.0", "react-native-gesture-handler": "~2.12.0", "react-native-htmlview": "^0.16.0", + "react-native-hyperlink": "^0.0.22", "react-native-ionicons": "^4.6.5", "react-native-modal-datetime-picker": "^17.1.0", "react-native-neat-date-picker": "^1.4.12", @@ -69,11 +74,11 @@ "react-native-vector-icons": "^10.0.2", "react-scroll-to-top": "^3.0.0", "use-debounce": "^10.0.0", - "validator": "^13.11.0", - "expo-image": "~1.3.5" + "validator": "^13.11.0" }, "devDependencies": { "@babel/core": "^7.20.0", + "@iconify/react": "^4.1.1", "@types/react": "~18.2.14", "@types/react-native": "^0.72.3", "@types/react-native-htmlview": "^0.16.1", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index eb7592c6..13c69336 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Icon from '../../../assets/icons'; import colors from '../../styles/colors'; +import globalStyles from '../../styles/globalStyles'; function HomeIcon({ color }: { color: string }) { return ( @@ -36,7 +37,7 @@ function TabNav() { - - + + + + + ); } diff --git a/src/app/(tabs)/genre/index.tsx b/src/app/(tabs)/genre/index.tsx index 04483ea9..924cbabf 100644 --- a/src/app/(tabs)/genre/index.tsx +++ b/src/app/(tabs)/genre/index.tsx @@ -7,18 +7,19 @@ import { Text, FlatList, } from 'react-native'; -import { MultiSelect } from 'react-native-element-dropdown'; -import { Icon } from 'react-native-elements'; + import { TouchableOpacity } from 'react-native-gesture-handler'; import { SafeAreaView } from 'react-native-safe-area-context'; import styles from './styles'; import BackButton from '../../../components/BackButton/BackButton'; + import PreviewCard from '../../../components/PreviewCard/PreviewCard'; import { fetchGenreStoryById } from '../../../queries/genres'; import { fetchStoryPreviewByIds } from '../../../queries/stories'; import { StoryPreview, GenreStories } from '../../../queries/types'; import globalStyles from '../../../styles/globalStyles'; +import { FilterDropdown } from '../../../components/FilterDropdown/FilterDropdown'; function GenreScreen() { const [genreStoryData, setGenreStoryData] = useState(); @@ -215,42 +216,6 @@ function GenreScreen() { ); }; - const renderFilterDropdown = ( - placeholder: string, - value: string[], - data: string[], - setter: React.Dispatch>, - ) => { - return ( - { - return { label: topic, value: topic }; - })} - renderSelectedItem={() => } - maxHeight={400} - labelField="label" - valueField="value" - placeholder={placeholder} - renderRightIcon={() => } - onChange={item => { - if (item) { - setter(item); - } - }} - /> - ); - }; - const renderNoStoryText = () => { return ( @@ -304,18 +269,18 @@ function GenreScreen() { - {renderFilterDropdown( - 'Tone', - selectedTonesForFiltering, - toneFilterOptions, - setSelectedTonesForFiltering, - )} - {renderFilterDropdown( - 'Topic', - selectedTopicsForFiltering, - topicFilterOptions, - setSelectedTopicsForFiltering, - )} + + {genreStoryIds.length === 0 && !isLoading ? ( diff --git a/src/app/(tabs)/genre/styles.tsx b/src/app/(tabs)/genre/styles.tsx index 8d69590b..181292de 100644 --- a/src/app/(tabs)/genre/styles.tsx +++ b/src/app/(tabs)/genre/styles.tsx @@ -1,6 +1,7 @@ import { StyleSheet } from 'react-native'; import colors from '../../../styles/colors'; +import globalStyles from '../../../styles/globalStyles'; const styles = StyleSheet.create({ textSelected: { @@ -12,7 +13,6 @@ const styles = StyleSheet.create({ width: '100%', flex: 1, }, - flatListStyle: { paddingTop: 15, }, diff --git a/src/app/(tabs)/home/index.tsx b/src/app/(tabs)/home/index.tsx index 911f8a57..62a11c92 100644 --- a/src/app/(tabs)/home/index.tsx +++ b/src/app/(tabs)/home/index.tsx @@ -3,10 +3,12 @@ import { router } from 'expo-router'; import { useEffect, useState } from 'react'; import { ActivityIndicator, ScrollView, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import Hyperlink from 'react-native-hyperlink'; import styles from './styles'; import ContentCard from '../../../components/ContentCard/ContentCard'; import PreviewCard from '../../../components/PreviewCard/PreviewCard'; + import { fetchUsername } from '../../../queries/profiles'; import { fetchFeaturedStoriesDescription, @@ -161,9 +163,19 @@ function HomeScreen() { {featuredStoriesHeader != '' && featuredStoriesDescription != null && featuredStoriesDescription != '' && ( - - {featuredStoriesDescription} - + + + {featuredStoriesDescription} + + )} {featuredStories.map(story => ( diff --git a/src/app/(tabs)/home/styles.ts b/src/app/(tabs)/home/styles.ts index 2b2627e8..9b8a8298 100644 --- a/src/app/(tabs)/home/styles.ts +++ b/src/app/(tabs)/home/styles.ts @@ -41,6 +41,10 @@ const styles = StyleSheet.create({ marginBottom: 24, marginRight: 24, }, + featuredDescriptionLink: { + color: 'blue', + textDecorationLine: 'underline', + }, }); export default styles; diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 2542da9e..3d50558a 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -13,7 +13,7 @@ import { fetchUserStoriesReadingList, } from '../../../queries/savedStories'; import { FlatList } from 'react-native-gesture-handler'; -import { usePubSub } from '../../../utils/PubSubContext'; +import { Channel, usePubSub } from '../../../utils/PubSubContext'; function LibraryScreen() { const { user } = useSession(); @@ -25,6 +25,7 @@ function LibraryScreen() { ); const { channels } = usePubSub(); let updateReadingListTimeout: NodeJS.Timeout | null = null; + let updateFavoritesListTimeout: NodeJS.Timeout | null = null; const favoritesPressed = () => { setFavoritesSelected(true); @@ -60,6 +61,20 @@ function LibraryScreen() { ); }; + useEffect(() => { + if (updateFavoritesListTimeout) { + clearTimeout(updateFavoritesListTimeout); + } + + updateFavoritesListTimeout = setTimeout( + () => + fetchUserStoriesFavorites(user?.id).then(favoriteStories => { + setFavoriteStories(favoriteStories); + }), + 4000, + ); + }, [channels[Channel.FAVORITES]]); + useEffect(() => { if (updateReadingListTimeout) { clearTimeout(updateReadingListTimeout); @@ -70,9 +85,9 @@ function LibraryScreen() { fetchUserStoriesReadingList(user?.id).then(readingList => { setReadingListStories(readingList); }), - 5000, + 4000, ); - }, [channels]); + }, [channels[Channel.SAVED_STORIES]]); useEffect(() => { (async () => { diff --git a/src/app/(tabs)/search/index.tsx b/src/app/(tabs)/search/index.tsx index 6163689c..8020b954 100644 --- a/src/app/(tabs)/search/index.tsx +++ b/src/app/(tabs)/search/index.tsx @@ -1,9 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { SearchBar } from '@rneui/themed'; import { router } from 'expo-router'; -import { Fragment, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { - Button, FlatList, View, Text, @@ -14,7 +13,6 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import styles from './styles'; -import FilterModal from '../../../components/FilterModal/FilterModal'; import GenreCard from '../../../components/GenreCard/GenreCard'; import PreviewCard from '../../../components/PreviewCard/PreviewCard'; import RecentSearchCard from '../../../components/RecentSearchCard/RecentSearchCard'; @@ -29,6 +27,10 @@ import { import colors from '../../../styles/colors'; import globalStyles from '../../../styles/globalStyles'; import { GenreType } from '../genre'; +import { + FilterDropdown, + FilterSingleDropdown, +} from '../../../components/FilterDropdown/FilterDropdown'; const getRecentSearch = async () => { try { @@ -74,6 +76,9 @@ function SearchScreen() { const [searchResults, setSearchResults] = useState< StoryPreviewWithPreloadedReactions[] >([]); + const [unfilteredSearchResults, setUnfilteredSearchResults] = useState< + StoryPreviewWithPreloadedReactions[] + >([]); const [search, setSearch] = useState(''); const [filterVisible, setFilterVisible] = useState(false); const [recentSearches, setRecentSearches] = useState([]); @@ -81,20 +86,164 @@ function SearchScreen() { const [showRecents, setShowRecents] = useState(false); const [recentlyViewed, setRecentlyViewed] = useState([]); const genreColors = [colors.citrus, colors.lime, colors.lilac]; + const [toneFilterOptions, setToneFilterOptions] = useState([]); + const [topicFilterOptions, setTopicFilterOptions] = useState([]); + const [genreFilterOptions, setGenreFilterOptions] = useState([]); + const [selectedTonesForFiltering, setSelectedTonesForFiltering] = useState< + string[] + >([]); + const [selectedTopicsForFiltering, setSelectedTopicsForFiltering] = useState< + string[] + >([]); + const [ + selectedMultipleGenresForFiltering, + setSelectedMultipleGenresForFiltering, + ] = useState([]); + const [selectedGenre, setSelectedGenre] = useState(''); + + const populateFilterDropdowns = (stories: StoryPreview[]) => { + const tones: string[] = stories + .reduce((acc: string[], current: StoryPreview) => { + return acc.concat(current.tone); + }, [] as string[]) + .filter(tone => tone !== null); + const topics: string[] = stories + .reduce((acc: string[], current: StoryPreview) => { + return acc.concat(current.topic); + }, [] as string[]) + .filter(topic => topic !== null); + const genres: string[] = stories + .reduce((acc: string[], current: StoryPreview) => { + return acc.concat(current.genre_medium); + }, [] as string[]) + .filter(genre => genre !== null); + + setGenreFilterOptions([...new Set(genres)]); + setTopicFilterOptions([...new Set(topics)]); + setToneFilterOptions([...new Set(tones)]); + }; useEffect(() => { (async () => { - fetchAllStoryPreviews().then(stories => setAllStories(stories)); - fetchGenres().then((genres: Genre[]) => setAllGenres(genres)); + fetchAllStoryPreviews().then( + (stories: StoryPreviewWithPreloadedReactions[]) => { + setAllStories(stories); + const tones: string[] = stories + .reduce((acc: string[], current: StoryPreview) => { + return acc.concat(current.tone); + }, [] as string[]) + .filter(tone => tone !== null); + const topics: string[] = stories + .reduce((acc: string[], current: StoryPreview) => { + return acc.concat(current.topic); + }, [] as string[]) + .filter(topic => topic !== null); + + setTopicFilterOptions([...new Set(topics)]); + setToneFilterOptions([...new Set(tones)]); + }, + ); + + fetchGenres().then((genres: Genre[]) => { + setAllGenres(genres); + const genreOptions: string[] = genres + .reduce((acc: string[], current: Genre) => { + return acc.concat( + current.parent_name, + current.subgenres.map(subgenre => subgenre.name), + ); + }, [] as string[]) + .filter(genre => genre !== null); + setGenreFilterOptions([...new Set(genreOptions)]); + }); getRecentSearch().then((searches: RecentSearch[]) => setRecentSearches(searches), ); getRecentStory().then((viewed: StoryPreview[]) => setRecentlyViewed(viewed), ); - })(); + })().then(() => {}); }, []); + useEffect(() => { + search.length > 0 + ? populateFilterDropdowns(searchResults) + : populateFilterDropdowns(allStories); + }, [search]); + + useEffect(() => { + if (selectedGenre) { + if (search.length === 0) { + const subgenreNames = allGenres + .reduce((acc: string[], current: Genre) => { + return acc.concat(current.subgenres.map(subgenre => subgenre.name)); + }, [] as string[]) + .filter(genre => genre !== null); + + if (subgenreNames.includes(selectedGenre)) { + const genre = allGenres.filter(genre => + genre.subgenres + .map(subgenre => subgenre.name) + .includes(selectedGenre), + )[0]; + router.push({ + pathname: '/genre', + params: { + genreId: genre.parent_id.toString(), + genreType: GenreType.SUBGENRE, + genreName: selectedGenre, + }, + }); + } else { + const genre = allGenres.filter( + genre => genre.parent_name === selectedGenre, + )[0]; + router.push({ + pathname: '/genre', + params: { + genreId: genre.parent_id.toString(), + genreType: GenreType.PARENT, + genreName: genre.parent_name, + }, + }); + } + } + } + }, [selectedGenre]); + + useEffect(() => { + const checkTopic = (preview: StoryPreview): boolean => { + if (preview == null || preview.topic == null) return false; + if (selectedTopicsForFiltering.length == 0) return true; + else + return selectedTopicsForFiltering.every(t => preview.topic.includes(t)); + }; + const checkTone = (preview: StoryPreview): boolean => { + if (preview == null || preview.tone == null) return false; + if (selectedTonesForFiltering.length == 0) return true; + else + return selectedTonesForFiltering.every(t => preview.tone.includes(t)); + }; + const checkGenre = (preview: StoryPreview): boolean => { + if (preview == null || preview.genre_medium == null) return false; + if (selectedMultipleGenresForFiltering.length == 0) return true; + else + return selectedMultipleGenresForFiltering.every(t => + preview.genre_medium.includes(t), + ); + }; + + const filteredPreviews = unfilteredSearchResults.filter( + preview => + checkTopic(preview) && checkTone(preview) && checkGenre(preview), + ); + setSearchResults(filteredPreviews); + }, [ + selectedTopicsForFiltering, + selectedTonesForFiltering, + selectedMultipleGenresForFiltering, + ]); + const getColor = (index: number) => { return genreColors[index % genreColors.length]; }; @@ -103,6 +252,7 @@ function SearchScreen() { if (text === '') { setSearch(text); setSearchResults([]); + setUnfilteredSearchResults([]); return; } @@ -115,11 +265,13 @@ function SearchScreen() { setSearch(text); setSearchResults(updatedData); + setUnfilteredSearchResults(updatedData); setShowGenreCarousals(false); }; const handleCancelButtonPress = () => { setSearchResults([]); + setUnfilteredSearchResults([]); setShowGenreCarousals(true); setShowRecents(false); }; @@ -134,6 +286,12 @@ function SearchScreen() { setRecentStory([]); }; + const clearFilters = () => { + setSelectedMultipleGenresForFiltering([]); + setSelectedTonesForFiltering([]); + setSelectedTopicsForFiltering([]); + }; + const searchResultStacking = ( searchString: string, searchResults: number, @@ -230,21 +388,70 @@ function SearchScreen() { }} /> - {search && ( - - - - - - - - - - - - Author's Process - - - - - { - router.push({ - pathname: '/author', - params: { author: story.author_id.toString() }, - }); - }} - > - - - - By {story.author_name} - - - - - - + + + )} ); diff --git a/src/app/(tabs)/story/styles.ts b/src/app/(tabs)/story/styles.ts index 0a2dc686..202e6220 100644 --- a/src/app/(tabs)/story/styles.ts +++ b/src/app/(tabs)/story/styles.ts @@ -1,5 +1,6 @@ import { StyleSheet } from 'react-native'; +import globalStyles from '../../../styles/globalStyles'; import colors from '../../../styles/colors'; const styles = StyleSheet.create({ @@ -7,6 +8,11 @@ const styles = StyleSheet.create({ paddingLeft: 24, paddingRight: 24, }, + image: { + width: '100%', + height: 200, + marginBottom: 16, + }, authorImage: { backgroundColor: '#D9D9D9', width: 21, @@ -28,9 +34,11 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', borderRadius: 10, marginBottom: 16, + marginTop: 15, }, genresBorder: { backgroundColor: '#D9D9D9', + padding: 10, paddingTop: 5, paddingBottom: 5, paddingLeft: 10, @@ -39,24 +47,67 @@ const styles = StyleSheet.create({ marginRight: 8, }, genresText: { - backgroundColor: '#D9D9D9', + fontFamily: 'Manrope-Regular', + fontSize: 12, + color: 'white', }, shareButtonText: { - color: colors.white, + fontFamily: 'Manrope-Regular', + fontSize: 14, + color: 'white', + marginLeft: -5, + textDecorationLine: 'underline', }, excerpt: { + fontFamily: 'Manrope-Regular', + fontSize: 16, textAlign: 'left', - paddingVertical: 16, + color: 'black', }, story: { + fontFamily: 'Manrope-Regular', + fontSize: 16, + textAlign: 'left', + color: 'black', marginBottom: 16, }, authorProcess: { - marginBottom: 16, + fontFamily: 'Manrope-Bold', + fontSize: 20, + textAlign: 'left', + color: 'black', + marginBottom: 5, + marginTop: 10, }, process: { + fontFamily: 'Manrope-Regular', + fontSize: 16, + textAlign: 'left', + color: 'black', marginBottom: 16, }, + backToTopButtonText: { + fontFamily: 'Manrope-Bold', + fontSize: 15, + textAlign: 'left', + color: 'black', + textDecorationLine: 'underline', + }, + bottomReactionContainer: { + flex: 1, + justifyContent: 'space-around', + }, + button_style: { + width: 125, + marginBottom: 16, + borderRadius: 8, + height: 35, + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + backgroundColor: '#EB563B', + }, }); export default styles; diff --git a/src/components/AuthorImage/AuthorImage.tsx b/src/components/AuthorImage/AuthorImage.tsx new file mode 100644 index 00000000..92b1dc88 --- /dev/null +++ b/src/components/AuthorImage/AuthorImage.tsx @@ -0,0 +1,43 @@ +import { router } from 'expo-router'; +import { Text, TouchableOpacity, View } from 'react-native'; +import { Image } from 'expo-image'; + +import styles from './styles'; +import globalStyles from '../../styles/globalStyles'; + +type AuthorImageProps = { + author_name: string; + author_id: string; + author_Uri: string; + // pressFunction: (event: GestureResponderEvent) => void; +}; + +function AuthorImage({ + author_name, + author_id, + author_Uri, // pressFunction, +}: AuthorImageProps) { + return ( + { + router.push({ + pathname: '/author', + params: { author: author_id }, + }); + }} + > + + Authors: + + + {author_name} + + + + ); +} + +export default AuthorImage; diff --git a/src/components/AuthorImage/styles.ts b/src/components/AuthorImage/styles.ts new file mode 100644 index 00000000..2309c278 --- /dev/null +++ b/src/components/AuthorImage/styles.ts @@ -0,0 +1,34 @@ +import { StyleSheet } from 'react-native'; + +import colors from '../../styles/colors'; +import globalStyles from '../../styles/globalStyles'; +export default StyleSheet.create({ + author: { + display: 'flex', + flexDirection: 'row', + gap: 10, + marginLeft: 12, + }, + authorText: { + fontFamily: 'Manrope-Regular', + fontSize: 15, + fontWeight: '400', + textAlign: 'left', + color: 'black', + }, + author_image: { + backgroundColor: '#D9D9D9', + width: 25, + height: 25, + borderRadius: 100 / 2, + }, + author_container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + }, + author_text: { + fontSize: 18, + fontWeight: 'bold', + }, +}); diff --git a/src/components/ContentCard/ContentCard.tsx b/src/components/ContentCard/ContentCard.tsx index b3c1d35f..181d0b9a 100644 --- a/src/components/ContentCard/ContentCard.tsx +++ b/src/components/ContentCard/ContentCard.tsx @@ -39,7 +39,7 @@ function ContentCard({ (async () => { const temp = await fetchAllReactionsToStory(id); if (temp != null) { - setReactions(temp.map(r => r.reaction)); + setReactions(temp); return; } @@ -76,8 +76,8 @@ function ContentCard({ - - + + diff --git a/src/components/FavoriteStoryButton/FavoriteStoryButton.tsx b/src/components/FavoriteStoryButton/FavoriteStoryButton.tsx new file mode 100644 index 00000000..ff8113cc --- /dev/null +++ b/src/components/FavoriteStoryButton/FavoriteStoryButton.tsx @@ -0,0 +1,85 @@ +import { useEffect, useMemo, useState } from 'react'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import Svg, { Path } from 'react-native-svg'; + +import { + addUserStoryToFavorites, + deleteUserStoryToFavorites, + isStoryInFavorites, +} from '../../queries/savedStories'; +import { useSession } from '../../utils/AuthContext'; +import { Channel, usePubSub } from '../../utils/PubSubContext'; + +type FavoriteStoryButtonProps = { + storyId: number; +}; + +export default function FavoriteStoryButton({ + storyId, +}: FavoriteStoryButtonProps) { + const { user } = useSession(); + const { channels, publish } = usePubSub(); + const [storyIsFavorited, setStoryIsFavorited] = useState(false); + + useEffect(() => { + isStoryInFavorites(storyId, user?.id).then(storyInReadingList => { + setStoryIsFavorited(storyInReadingList); + }); + }, [storyId]); + + useEffect(() => { + isStoryInFavorites(storyId, user?.id).then(storyInFavorites => { + setStoryIsFavorited(storyInFavorites); + publish(Channel.FAVORITES, storyId, storyInFavorites); + }); + }, [storyId]); + + useEffect(() => { + const value = channels[Channel.FAVORITES][storyId]; + if (value == undefined) { + return; + } + + setStoryIsFavorited(value); + }, [channels[Channel.FAVORITES][storyId]]); + + const favoriteStory = async (favorited: boolean) => { + setStoryIsFavorited(favorited); + + if (favorited) { + publish(Channel.FAVORITES, storyId, true); + await addUserStoryToFavorites(user?.id, storyId); + } else { + publish(Channel.FAVORITES, storyId, false); + await deleteUserStoryToFavorites(user?.id, storyId); + } + }; + + const renderFavoritedIcon = useMemo(() => { + return ( + + + + ); + }, []); + + const renderNotFavoritedIcon = useMemo(() => { + return ( + + + + ); + }, []); + + return ( + favoriteStory(!storyIsFavorited)}> + {storyIsFavorited ? renderFavoritedIcon : renderNotFavoritedIcon} + + ); +} diff --git a/src/components/FilterDropdown/FilterDropdown.tsx b/src/components/FilterDropdown/FilterDropdown.tsx new file mode 100644 index 00000000..2ba8c7e6 --- /dev/null +++ b/src/components/FilterDropdown/FilterDropdown.tsx @@ -0,0 +1,107 @@ +import { Dropdown, MultiSelect } from 'react-native-element-dropdown'; +import globalStyles from '../../styles/globalStyles'; +import styles from './styles'; +import { View } from 'react-native'; +import { Icon } from 'react-native-elements'; + +import colors from '../../styles/colors'; + +type FilterDropdownProps = { + placeholder: string; + value: string[]; + data: string[]; + selectedBorderColor?: string; + setter: React.Dispatch>; +}; + +function FilterDropdown({ + placeholder, + value, + data, + setter, + selectedBorderColor = colors.darkGrey, +}: FilterDropdownProps) { + return ( + 0 ? { borderColor: selectedBorderColor } : {}, + ]} + value={value} + placeholderStyle={[globalStyles.body1, styles.placeholderStyle]} + selectedTextStyle={globalStyles.body1} + inputSearchStyle={globalStyles.body1} + itemTextStyle={globalStyles.body1} + dropdownPosition="bottom" + itemContainerStyle={styles.itemContainer} + iconStyle={styles.iconStyle} + data={data.map(topic => { + return { label: topic, value: topic }; + })} + renderSelectedItem={() => } + maxHeight={400} + labelField="label" + valueField="value" + placeholder={placeholder} + renderRightIcon={() => ( + 0 ? selectedBorderColor : colors.darkGrey} + name="arrow-drop-down" + type="material" + /> + )} + onChange={item => { + if (item) { + setter(item); + } + }} + /> + ); +} + +type FilterSingleDropdownProps = { + placeholder: string; + value: string; + data: string[]; + setter: React.Dispatch>; +}; + +function FilterSingleDropdown({ + placeholder, + value, + data, + setter, +}: FilterSingleDropdownProps) { + return ( + { + return { label: topic, value: topic }; + })} + maxHeight={400} + labelField="label" + valueField="value" + placeholder={placeholder} + renderRightIcon={() => ( + + )} + onChange={item => { + if (item) { + setter(item.value); + } + }} + /> + ); +} + +export { FilterDropdown, FilterSingleDropdown }; diff --git a/src/components/FilterDropdown/styles.ts b/src/components/FilterDropdown/styles.ts new file mode 100644 index 00000000..2ff390db --- /dev/null +++ b/src/components/FilterDropdown/styles.ts @@ -0,0 +1,38 @@ +import { StyleSheet } from 'react-native'; + +import colors from '../../styles/colors'; + +const styles = StyleSheet.create({ + dropdown: { + borderColor: colors.darkGrey, + flexGrow: 0, + flexShrink: 0, + borderWidth: 1, + borderRadius: 7, + width: 105, + height: 30, + marginRight: 8, + color: colors.darkGrey, + }, + dropdownContainer: { + marginTop: 20, + marginBottom: 20, + flexDirection: 'row', + justifyContent: 'flex-start', + }, + firstDropdown: {}, + iconStyle: { + width: 20, + height: 20, + }, + itemContainer: {}, + placeholderStyle: { + marginBottom: 6, + marginTop: 3, + paddingLeft: 10, + fontSize: 14, + color: colors.darkGrey, + }, +}); + +export default styles; diff --git a/src/components/FilterModal/ChildFilter.tsx b/src/components/FilterModal/ChildFilter.tsx new file mode 100644 index 00000000..3b93abd1 --- /dev/null +++ b/src/components/FilterModal/ChildFilter.tsx @@ -0,0 +1,27 @@ +import { CheckBox } from '@rneui/themed'; +import { memo } from 'react'; + +type ChildFilterProps = { + id: number; + name: string; + checked: boolean; + onPress: (id: number) => void; +}; + +function ChildFilter({ id, name, checked, onPress }: ChildFilterProps) { + return ( + onPress(id)} + iconType="material-community" + checkedIcon="checkbox-marked" + uncheckedIcon="checkbox-blank-outline" + checkedColor="black" + /> + ); +} + +export default memo(ChildFilter); diff --git a/src/components/FilterModal/FilterModal.tsx b/src/components/FilterModal/FilterModal.tsx index c2ded653..16fc7263 100644 --- a/src/components/FilterModal/FilterModal.tsx +++ b/src/components/FilterModal/FilterModal.tsx @@ -1,11 +1,14 @@ import { BottomSheet, CheckBox } from '@rneui/themed'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { View, Text, ScrollView, Pressable } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import 'react-native-gesture-handler'; +import ChildFilter from './ChildFilter'; +import ParentFilter from './ParentFilter'; import styles from './styles'; import Icon from '../../../assets/icons'; +import { TagFilter, useFilter } from '../../utils/FilterContext'; type FilterModalProps = { isVisible: boolean; @@ -13,33 +16,32 @@ type FilterModalProps = { title: string; }; +export enum CATEGORIES { + GENRE = 'genre-medium', + TOPIC = 'topic', + TONE = 'tone', +} + function FilterModal({ isVisible, setIsVisible, title }: FilterModalProps) { - const [checked1, toggleChecked1] = useState(false); - const [checked2, toggleChecked2] = useState(false); - const [checked3, toggleChecked3] = useState(false); + const { dispatch, filters } = useFilter(); - const genres = [ - { - title: 'Fiction', - state: checked1, - setState: toggleChecked1, + const toggleParentFilter = useCallback( + (id: number) => { + dispatch({ type: 'TOGGLE_MAIN_GENRE', mainGenreId: id }); }, - { - title: 'Erasure & Found Poetry', - state: checked2, - setState: toggleChecked2, - }, - { - title: 'Non-Fiction', - state: checked3, - setState: toggleChecked3, + [dispatch], + ); + + const toggleChildFilter = useCallback( + (id: number) => { + dispatch({ type: 'TOGGLE_FILTER', id }); }, - ]; + [dispatch], + ); return ( {title} - - {genres.map(item => { + { + const [_, parentFilter] = item; return ( - item.setState(!item.state)} - iconType="material-community" - checkedIcon="checkbox-marked" - uncheckedIcon="checkbox-blank-outline" - checkedColor="black" - /> + <> + + + { + return ( + + ); + }} + /> + ); - })} - + }} + /> diff --git a/src/components/FilterModal/ParentFilter.tsx b/src/components/FilterModal/ParentFilter.tsx new file mode 100644 index 00000000..d763e0a5 --- /dev/null +++ b/src/components/FilterModal/ParentFilter.tsx @@ -0,0 +1,26 @@ +import { CheckBox } from '@rneui/themed'; +import { memo } from 'react'; + +type ParentFilterProps = { + id: number; + name: string; + checked: boolean; + onPress: (id: number) => void; +}; + +function ParentFilter({ id, name, checked, onPress }: ParentFilterProps) { + return ( + onPress(id)} + iconType="material-community" + checkedIcon="checkbox-marked" + uncheckedIcon="checkbox-blank-outline" + checkedColor="black" + /> + ); +} + +export default memo(ParentFilter); diff --git a/src/components/GenreCard/GenreCard.tsx b/src/components/GenreCard/GenreCard.tsx index 37bb5360..016fb9af 100644 --- a/src/components/GenreCard/GenreCard.tsx +++ b/src/components/GenreCard/GenreCard.tsx @@ -6,6 +6,7 @@ import { } from 'react-native'; import styles from './styles'; +import globalStyles from '../../styles/globalStyles'; type GenreCardProps = { subgenres: string; @@ -18,7 +19,9 @@ function GenreCard({ subgenres, pressFunction, cardColor }: GenreCardProps) { return ( - {subgenres} + + {subgenres} + ); diff --git a/src/components/GenreCard/styles.ts b/src/components/GenreCard/styles.ts index 0bededf3..dcd382b5 100644 --- a/src/components/GenreCard/styles.ts +++ b/src/components/GenreCard/styles.ts @@ -16,7 +16,6 @@ const styles = StyleSheet.create({ overlayText: { color: colors.white, textAlign: 'right', - fontSize: 14, }, }); diff --git a/src/components/OptionBar/OptionBar.tsx b/src/components/OptionBar/OptionBar.tsx new file mode 100644 index 00000000..783dae66 --- /dev/null +++ b/src/components/OptionBar/OptionBar.tsx @@ -0,0 +1,50 @@ +import { Share, View } from 'react-native'; +import Icon from '../../../assets/icons'; +import ReactionPicker from '../ReactionPicker/ReactionPicker'; +import SaveStoryButton from '../SaveStoryButton/SaveStoryButton'; +import FavoriteStoryButton from '../FavoriteStoryButton/FavoriteStoryButton'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import styles from './styles'; +import { Story } from '../../queries/types'; + +type OptionBarProps = { + storyId: number; + story: Story; +}; + +function OptionBar({ storyId, story }: OptionBarProps) { + const onShare = async () => { + try { + const result = await Share.share({ + message: `Check out this story from Girls Write Now! \n${story.link}/`, + }); + if (result.action === Share.sharedAction) { + if (result.activityType) { + // shared with activity type of result.activityType + } else { + // shared + } + } else if (result.action === Share.dismissedAction) { + // dismissed + } + } catch (error) { + console.log(error); + } + }; + + return ( + + + + + + + + + + + + ); +} + +export default OptionBar; diff --git a/src/components/OptionBar/styles.ts b/src/components/OptionBar/styles.ts new file mode 100644 index 00000000..6dcb70fa --- /dev/null +++ b/src/components/OptionBar/styles.ts @@ -0,0 +1,19 @@ +import { StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + options: { + backgroundColor: 'white', + paddingTop: 16, + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between', + paddingBottom: 22, + }, + right: { + alignSelf: 'flex-end', + gap: 8, + flexDirection: 'row', + }, +}); + +export default styles; diff --git a/src/components/PreviewCard/PreviewCard.tsx b/src/components/PreviewCard/PreviewCard.tsx index 71f3589b..53164d4e 100644 --- a/src/components/PreviewCard/PreviewCard.tsx +++ b/src/components/PreviewCard/PreviewCard.tsx @@ -1,6 +1,6 @@ import * as cheerio from 'cheerio'; import { Image } from 'expo-image'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { GestureResponderEvent, Pressable, @@ -24,7 +24,7 @@ type PreviewCardProps = { storyId: number; author: string; authorImage: string; - defaultSavedStoriesState?: boolean; + defaultSavedStoriesState?: boolean | null; excerpt: { html: string }; tags: string[]; reactions?: string[] | null; @@ -39,7 +39,7 @@ function PreviewCard({ authorImage, excerpt, tags, - defaultSavedStoriesState = false, + defaultSavedStoriesState = null, pressFunction, reactions: preloadedReactions = null, }: PreviewCardProps) { @@ -54,7 +54,7 @@ function PreviewCard({ (async () => { const temp = await fetchAllReactionsToStory(storyId); if (temp != null) { - setReactions(temp.map(r => r.reaction)); + setReactions(temp.filter(r => r != null)); return; } setReactions([]); @@ -101,7 +101,7 @@ function PreviewCard({ - + {(tags?.length ?? 0) > 0 && ( diff --git a/src/components/ReactionDisplay/ReactionDisplay.tsx b/src/components/ReactionDisplay/ReactionDisplay.tsx index 6b70eb1e..22f490b0 100644 --- a/src/components/ReactionDisplay/ReactionDisplay.tsx +++ b/src/components/ReactionDisplay/ReactionDisplay.tsx @@ -2,20 +2,27 @@ import { Text, View } from 'react-native'; import styles from './styles'; import Emoji from 'react-native-emoji'; import globalStyles from '../../styles/globalStyles'; +import { Channel, usePubSub } from '../../utils/PubSubContext'; +import { useEffect, useState } from 'react'; type ReactionDisplayProps = { reactions: (string | null)[]; + storyId: number; }; -function ReactionDisplay({ reactions }: ReactionDisplayProps) { - const cleanedReactions = reactions.filter(reaction => reaction !== null); - const reactionColors: Record = { - heart: '#FFCCCB', - clap: '#FFD580', - cry: '#89CFF0', - hugging_face: '#ffc3bf', - muscle: '#eddcf7', - }; +const reactionColors: Record = { + heart: '#FFCCCB', + clap: '#FFD580', + cry: '#89CFF0', + hugging_face: '#ffc3bf', + muscle: '#eddcf7', +}; + +function ReactionDisplay({ reactions, storyId }: ReactionDisplayProps) { + const { channels, getPubSubValue } = usePubSub(); + const [reactionCount, setReactionCount] = useState(0); + + const cleanedReactions = reactions.filter(reaction => reaction != null); const defaultColor = reactionColors['heart']; const setOfReactions = [...cleanedReactions]; setOfReactions.push('heart'); @@ -23,6 +30,24 @@ function ReactionDisplay({ reactions }: ReactionDisplayProps) { setOfReactions.push('muscle'); const reactionDisplay = [...new Set(setOfReactions)].slice(0, 3); + const serverReactionCount = cleanedReactions?.length ?? 0; + + useEffect(() => { + setReactionCount(serverReactionCount); + }, [reactions]); + + useEffect(() => { + const value = getPubSubValue(Channel.REACTIONS, storyId); + if (value == undefined) { + return; + } + + if (value) { + setReactionCount(serverReactionCount + 1); + } else { + setReactionCount(serverReactionCount); + } + }, [channels[Channel.REACTIONS][storyId]]); return ( {reactionDisplay.map(reaction => { - if (reaction === null) return; + if (reaction == null) return; return ( - {cleanedReactions?.length ?? 0} + {reactionCount} diff --git a/src/components/ReactionPicker/ReactionPicker.tsx b/src/components/ReactionPicker/ReactionPicker.tsx new file mode 100644 index 00000000..71f1455d --- /dev/null +++ b/src/components/ReactionPicker/ReactionPicker.tsx @@ -0,0 +1,81 @@ +import { faFaceSmile } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { useEffect, useState } from 'react'; +import { View, TouchableOpacity } from 'react-native'; + +import styles from './styles'; +import Emoji from 'react-native-emoji'; +import { + addReactionToStory, + deleteReactionToStory, +} from '../../queries/reactions'; +import { useSession } from '../../utils/AuthContext'; +import { Channel, usePubSub } from '../../utils/PubSubContext'; + +type ReactionPickerProps = { + storyId: number; +}; + +const ReactionPicker = ({ storyId }: ReactionPickerProps) => { + const { user } = useSession(); + const { publish } = usePubSub(); + const [showReactions, setShowReactions] = useState(false); + const [currentReaction, setCurrentReaction] = useState(''); + + const toggleReactions = () => setShowReactions(!showReactions); + const reactionMapping: Record = { + heart: 2, + clap: 3, + muscle: 4, + cry: 5, + hugging_face: 6, + }; + + const handleReactionPress = (reactionName: string) => { + if (currentReaction == reactionName) { + removeReaction(reactionName); + } else { + addReaction(reactionName); + } + }; + + const addReaction = (reactionName: string) => { + setCurrentReaction(reactionName); + publish(Channel.REACTIONS, storyId, true); + + const reactionId = reactionMapping[reactionName]; + addReactionToStory(user?.id, storyId, reactionId); + }; + + const removeReaction = (reactionName: string) => { + setCurrentReaction(''); + publish(Channel.REACTIONS, storyId, false); + + const reactionId = reactionMapping[reactionName]; + deleteReactionToStory(user?.id, storyId, reactionId); + }; + + return ( + + + + + {showReactions && ( + <> + + {Object.keys(reactionMapping).map((reaction, i) => ( + handleReactionPress(reaction)} + > + + + ))} + + )} + + + ); +}; + +export default ReactionPicker; diff --git a/src/components/ReactionPicker/styles.ts b/src/components/ReactionPicker/styles.ts new file mode 100644 index 00000000..3d46e32d --- /dev/null +++ b/src/components/ReactionPicker/styles.ts @@ -0,0 +1,29 @@ +import { StyleSheet } from 'react-native'; + +import colors from '../../styles/colors'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'flex-end', + // flexDirection: 'row' + }, + reactionView: { + borderRadius: 20, + padding: 10, + alignSelf: 'center', + }, + reactionsContainer: { + flex: 1, + flexDirection: 'row', + gap: 5, + justifyContent: 'space-between', + padding: 10, + position: 'absolute', // Positioning the container above the toggle button + bottom: -2, + backgroundColor: '#D9D9D9', + borderRadius: 20, + }, +}); + +export default styles; diff --git a/src/components/SaveStoryButton/SaveStoryButton.tsx b/src/components/SaveStoryButton/SaveStoryButton.tsx index be44ebb9..3e22084e 100644 --- a/src/components/SaveStoryButton/SaveStoryButton.tsx +++ b/src/components/SaveStoryButton/SaveStoryButton.tsx @@ -1,22 +1,20 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { TouchableOpacity } from 'react-native-gesture-handler'; +import Svg, { Path } from 'react-native-svg'; + import { addUserStoryToReadingList, deleteUserStoryToReadingList, isStoryInReadingList, } from '../../queries/savedStories'; -import { usePubSub } from '../../utils/PubSubContext'; +import { Channel, usePubSub } from '../../utils/PubSubContext'; import { useSession } from '../../utils/AuthContext'; -import { Image } from 'expo-image'; -import { TouchableOpacity } from 'react-native-gesture-handler'; type SaveStoryButtonProps = { storyId: number; defaultState?: boolean | null; }; -const saveStoryImage = require('../../../assets/save_story.png'); -const savedStoryImage = require('../../../assets/saved_story.png'); - export default function SaveStoryButton({ storyId, defaultState = null, @@ -25,7 +23,7 @@ export default function SaveStoryButton({ const [storyIsSaved, setStoryIsSaved] = useState( defaultState, ); - const { channels, initializeChannel, publish } = usePubSub(); + const { publish, channels, getPubSubValue } = usePubSub(); useEffect(() => { if (defaultState != null) { @@ -34,20 +32,18 @@ export default function SaveStoryButton({ isStoryInReadingList(storyId, user?.id).then(storyInReadingList => { setStoryIsSaved(storyInReadingList); - initializeChannel(storyId); }); }, [storyId]); useEffect(() => { - // if another card updates this story, update it here also - if (typeof channels[storyId] !== 'undefined') { - setStoryIsSaved(channels[storyId] ?? false); + if (getPubSubValue(Channel.SAVED_STORIES, storyId) != null) { + setStoryIsSaved(getPubSubValue(Channel.SAVED_STORIES, storyId) ?? false); } - }, [channels[storyId]]); + }, [channels[Channel.SAVED_STORIES][storyId]]); const saveStory = async (saved: boolean) => { setStoryIsSaved(saved); - publish(storyId, saved); // update other cards with this story + publish(Channel.SAVED_STORIES, storyId, saved); // update other cards with this story if (saved) { await addUserStoryToReadingList(user?.id, storyId); @@ -56,13 +52,31 @@ export default function SaveStoryButton({ } }; + const renderSavedStoryImage = useMemo(() => { + return ( + + + + ); + }, []); + + const renderSaveStoryImage = useMemo(() => { + return ( + + + + ); + }, []); + return ( saveStory(!storyIsSaved)}> - {storyIsSaved ? ( - - ) : ( - - )} + {storyIsSaved ? renderSavedStoryImage : renderSaveStoryImage} ); } diff --git a/src/queries/reactions.tsx b/src/queries/reactions.tsx index ffe68c40..f72c9e00 100644 --- a/src/queries/reactions.tsx +++ b/src/queries/reactions.tsx @@ -2,7 +2,7 @@ import { Reactions } from './types'; import supabase from '../utils/supabase'; export async function addReactionToStory( - input_profile_id: number, + input_profile_id: string | undefined, input_story_id: number, input_reaction_id: number, ): Promise { @@ -22,7 +22,7 @@ export async function addReactionToStory( } export async function deleteReactionToStory( - input_profile_id: number, + input_profile_id: string | undefined, input_story_id: number, input_reaction_id: number, ): Promise { @@ -43,17 +43,18 @@ export async function deleteReactionToStory( export async function fetchAllReactionsToStory( storyId: number, -): Promise { +): Promise { const { data, error } = await supabase.rpc('curr_get_reactions_for_story', { - input_story_id: storyId, + story_id: storyId, }); + if (error) { console.log(error); throw new Error( `An error occured when trying to fetch reactions to a story', ${error}`, ); } else { - return data as Reactions[]; + return data[0].reactions; } } diff --git a/src/queries/savedStories.tsx b/src/queries/savedStories.tsx index 5ab847d7..13aeae64 100644 --- a/src/queries/savedStories.tsx +++ b/src/queries/savedStories.tsx @@ -48,7 +48,7 @@ async function addUserStory( ) { const { error } = await supabase .from('saved_stories') - .upsert([{ user_id: user_id, story_id: story_id, name: name }]) + .upsert([{ user_id, story_id, name }]) .select(); if (error) { @@ -128,3 +128,21 @@ export async function isStoryInReadingList( return data; } + +export async function isStoryInFavorites( + storyId: number, + userId: string | undefined, +): Promise { + const { data, error } = await supabase.rpc('is_story_saved_for_user', { + list_name: 'favorites', + story_db_id: storyId, + user_uuid: userId, + }); + + if (error) { + console.error(error); + return false; + } + + return data; +} diff --git a/src/queries/stories.tsx b/src/queries/stories.tsx index a42b2a33..50574f8e 100644 --- a/src/queries/stories.tsx +++ b/src/queries/stories.tsx @@ -151,32 +151,32 @@ export async function fetchNewStories(): Promise { } } -export async function fetchStoryPreviewById( - storyId: number, +export async function fetchStoryPreviewByIds( + storyIds: number[], ): Promise { - const { data, error } = await supabase.rpc('curr_story_preview_by_id', { - input_story_id: storyId, + const { data, error } = await supabase.rpc('curr_story_preview_by_ids', { + input_ids: storyIds, }); if (error) { console.log(error); throw new Error( - `An error occured when trying to fetch story preview by ID: ${error}`, + `An error occured when trying to fetch story preview by IDs: ${error}`, ); } else { return data; } } -export async function fetchStoryPreviewByIds( - storyIds: number[], +export async function fetchStoryPreviewById( + storyId: number, ): Promise { - const { data, error } = await supabase.rpc('curr_story_preview_by_ids', { - input_ids: storyIds, + const { data, error } = await supabase.rpc('curr_story_preview_by_id', { + input_story_id: storyId, }); if (error) { console.log(error); throw new Error( - `An error occured when trying to fetch story preview by IDs: ${error}`, + `An error occured when trying to fetch story preview by ID: ${error}`, ); } else { return data; diff --git a/src/utils/PubSubContext.tsx b/src/utils/PubSubContext.tsx index fbb9ada6..9079389f 100644 --- a/src/utils/PubSubContext.tsx +++ b/src/utils/PubSubContext.tsx @@ -1,9 +1,17 @@ -import React, { createContext, useContext, useMemo, useState } from 'react'; +import React, { createContext, useContext, useState } from 'react'; + +export enum Channel { + REACTIONS = 'reactions', + SAVED_STORIES = 'saved_stories', + FAVORITES = 'favorites', +} + +type channel = Record; export interface PubSubState { - channels: Record; - initializeChannel: (id: number) => void; - publish: (id: number, message: boolean) => void; + channels: Record; + publish: (channel: Channel, id: number, message: boolean) => void; + getPubSubValue: (channel: Channel, id: number) => boolean | undefined; } const BooleanPubSubContext = createContext({} as PubSubState); @@ -26,28 +34,26 @@ export function BooleanPubSubProvider({ }: { children: React.ReactNode; }) { - const [channels, setChannels] = useState>( - {}, - ); - - const initializeChannel = (id: number) => { - if (!(id in channels)) { - setChannels({ ...channels, [id]: undefined }); - } + const [channels, setChannels] = useState>({ + [Channel.FAVORITES]: {}, + [Channel.REACTIONS]: {}, + [Channel.SAVED_STORIES]: {}, + }); + + const publish = (channel: Channel, id: number, message: boolean) => { + let thisChannel = { ...channels[channel], [id]: message }; + setChannels({ ...channels, [channel]: thisChannel }); }; - const publish = (id: number, message: boolean) => { - setChannels({ ...channels, [id]: message }); + const getPubSubValue = (channel: Channel, id: number) => { + return channels[channel][id]; }; - const authContextValue = useMemo( - () => ({ - channels, - initializeChannel, - publish, - }), - [channels], - ); + const authContextValue = { + channels, + publish, + getPubSubValue, + }; return (