From 47b3c11533e7b944adbce3b0f8a1d46bbb6bdf2c Mon Sep 17 00:00:00 2001 From: Aditya Pawar Date: Wed, 17 Apr 2024 17:39:35 -0700 Subject: [PATCH] Add pubsub to sync preview and content cards --- package-lock.json | 9 +++ package.json | 3 +- src/app/_layout.tsx | 13 ++-- src/components/AuthorCard/AuthorCard.tsx | 4 +- src/components/ContentCard/ContentCard.tsx | 63 +++++++++++++++----- src/components/PreviewCard/PreviewCard.tsx | 69 +++++++++++++++++----- src/components/PreviewCard/styles.ts | 2 + src/queries/savedStories.tsx | 26 ++++---- src/utils/AuthContext.tsx | 12 ++-- src/utils/PubSubContext.tsx | 60 +++++++++++++++++++ 10 files changed, 202 insertions(+), 59 deletions(-) create mode 100644 src/utils/PubSubContext.tsx diff --git a/package-lock.json b/package-lock.json index f83fb7da..68298471 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,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", @@ -11174,6 +11175,14 @@ "react-native": "*" } }, + "node_modules/expo-image": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-1.3.5.tgz", + "integrity": "sha512-yrIR2mnfIKbKcguoqWK3U5m3zvLPnonvSCabB2ErVGhws8zQs7ILYf+7T08j8U6eFcohjw0CoAFJ6RWNsX2EhA==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-12.3.0.tgz", diff --git a/package.json b/package.json index ed0b39df..d15d3b50 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "react-native-vector-icons": "^10.0.2", "react-scroll-to-top": "^3.0.0", "use-debounce": "^10.0.0", - "validator": "^13.11.0" + "validator": "^13.11.0", + "expo-image": "~1.3.5" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index c15ba80c..b8f05279 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -2,6 +2,7 @@ import { Stack } from 'expo-router'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { AuthContextProvider } from '../utils/AuthContext'; +import { BooleanPubSubProvider } from '../utils/PubSubContext'; import ToastComponent from '../components/Toast/Toast'; import { Keyboard, TouchableWithoutFeedback } from 'react-native'; @@ -9,11 +10,13 @@ function StackLayout() { return ( - - - - - + + + + + + + diff --git a/src/components/AuthorCard/AuthorCard.tsx b/src/components/AuthorCard/AuthorCard.tsx index afdd2b57..99694abd 100644 --- a/src/components/AuthorCard/AuthorCard.tsx +++ b/src/components/AuthorCard/AuthorCard.tsx @@ -1,7 +1,5 @@ -import { Image, Pressable, Text, View } from 'react-native'; - +import { Image, Text, View } from 'react-native'; import styles from './styles'; -import globalStyles from '../../styles/globalStyles'; type AuthorCardProps = { name: string; diff --git a/src/components/ContentCard/ContentCard.tsx b/src/components/ContentCard/ContentCard.tsx index a6e32b61..034ff6fa 100644 --- a/src/components/ContentCard/ContentCard.tsx +++ b/src/components/ContentCard/ContentCard.tsx @@ -1,19 +1,23 @@ import { GestureResponderEvent, - Image, Pressable, Text, View, TouchableOpacity, } from 'react-native'; +import { Image } from 'expo-image'; import styles from './styles'; -import { addUserStoryToReadingList, deleteUserStoryToReadingList, isStoryInReadingList } from '../../queries/savedStories'; +import { + addUserStoryToReadingList, + deleteUserStoryToReadingList, + isStoryInReadingList, +} from '../../queries/savedStories'; import globalStyles from '../../styles/globalStyles'; import { useSession } from '../../utils/AuthContext'; import Emoji from 'react-native-emoji'; -import { useEffect, useMemo, useState } from 'react'; - +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { usePubSub } from '../../utils/PubSubContext'; type ContentCardProps = { title: string; @@ -37,19 +41,47 @@ function ContentCard({ }: ContentCardProps) { const { user } = useSession(); const [storyIsSaved, setStoryIsSaved] = useState(false); + const { channels, initializeChannel, publish } = usePubSub(); + + const savedStoryImageComponent = useMemo(() => { + return ( + + ) + }, []) + const saveStoryImageComponent = useMemo(() => { + return ( + + ) + }, []) useEffect(() => { - isStoryInReadingList(storyId, user?.id).then(storyInReadingList => setStoryIsSaved(storyInReadingList)) - }, [storyId]) + 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]); + } + }, [channels[storyId]]); - const saveStory = () => { - if (storyIsSaved) { - deleteUserStoryToReadingList(user?.id, storyId); + const saveStory = async (saved: boolean) => { + setStoryIsSaved(saved); + publish(storyId, saved); + if (saved) { + await addUserStoryToReadingList(user?.id, storyId); } else { - addUserStoryToReadingList(user?.id, storyId); + await deleteUserStoryToReadingList(user?.id, storyId); } - setStoryIsSaved(!storyIsSaved); }; return ( @@ -98,11 +130,10 @@ function ContentCard({ - saveStory()}> - + saveStory(!storyIsSaved)}> + {storyIsSaved ? + savedStoryImageComponent : saveStoryImageComponent + } diff --git a/src/components/PreviewCard/PreviewCard.tsx b/src/components/PreviewCard/PreviewCard.tsx index db5b283f..fcb7b0b2 100644 --- a/src/components/PreviewCard/PreviewCard.tsx +++ b/src/components/PreviewCard/PreviewCard.tsx @@ -1,20 +1,25 @@ import * as cheerio from 'cheerio'; import { GestureResponderEvent, - Image, Pressable, Text, TouchableOpacity, View, } from 'react-native'; import Emoji from 'react-native-emoji'; +import { Image } from 'expo-image'; import styles from './styles'; import globalStyles from '../../styles/globalStyles'; -import { useEffect, useState } from 'react'; -import { addUserStoryToReadingList, deleteUserStoryToReadingList, isStoryInReadingList } from '../../queries/savedStories'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + addUserStoryToReadingList, + deleteUserStoryToReadingList, + isStoryInReadingList, +} from '../../queries/savedStories'; import { useSession } from '../../utils/AuthContext'; import { useIsFocused } from '@react-navigation/native'; +import { usePubSub } from '../../utils/PubSubContext'; const placeholderImage = 'https://gwn-uploads.s3.amazonaws.com/wp-content/uploads/2021/10/10120952/Girls-Write-Now-logo-avatar.png'; @@ -45,19 +50,54 @@ function PreviewCard({ const { user } = useSession(); const isFocused = useIsFocused(); const [storyIsSaved, setStoryIsSaved] = useState(false); + const { channels, initializeChannel, publish } = usePubSub(); + + const savedStoryImageComponent = useMemo(() => { + return ( + + ) + }, []) + const saveStoryImageComponent = useMemo(() => { + return ( + + ) + }, []) useEffect(() => { - isStoryInReadingList(storyId, user?.id).then(storyInReadingList => setStoryIsSaved(storyInReadingList)) - }, [storyId, isFocused]) + 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]) + } + }, [channels[storyId]]); + + + useEffect(() => { + isStoryInReadingList(storyId, user?.id).then(storyInReadingList => + setStoryIsSaved(storyInReadingList), + ); + }, [storyId, isFocused]); - const saveStory = () => { - if (storyIsSaved) { - deleteUserStoryToReadingList(user?.id, storyId); + const saveStory = async (saved: boolean) => { + setStoryIsSaved(saved); + publish(storyId, saved); + if (saved) { + await addUserStoryToReadingList(user?.id, storyId); } else { - addUserStoryToReadingList(user?.id, storyId); + await deleteUserStoryToReadingList(user?.id, storyId); } - setStoryIsSaved(!storyIsSaved); }; return ( @@ -67,11 +107,10 @@ function PreviewCard({ {title} - saveStory()}> - + saveStory(!storyIsSaved)}> + {storyIsSaved ? + savedStoryImageComponent : saveStoryImageComponent + } diff --git a/src/components/PreviewCard/styles.ts b/src/components/PreviewCard/styles.ts index ed355c7e..d8d3ef1d 100644 --- a/src/components/PreviewCard/styles.ts +++ b/src/components/PreviewCard/styles.ts @@ -50,9 +50,11 @@ const styles = StyleSheet.create({ paddingTop: 16, paddingLeft: 12, paddingRight: 12, + paddingBottom: 8, borderBottomColor: '#EBEBEB', borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: 'row', + flexGrow: 1, justifyContent: 'space-between', }, tag: { diff --git a/src/queries/savedStories.tsx b/src/queries/savedStories.tsx index 8b884607..78c4e53d 100644 --- a/src/queries/savedStories.tsx +++ b/src/queries/savedStories.tsx @@ -2,10 +2,9 @@ import supabase from '../utils/supabase'; enum SavedList { FAVORITES = 'favorites', - READING_LIST = 'reading list' + READING_LIST = 'reading list', } - async function fetchUserStories( user_id: string | undefined, name: string | undefined, @@ -65,13 +64,13 @@ async function addUserStory( ) { const { error } = await supabase .from('saved_stories') - .insert([{ user_id: user_id, story_id: story_id, name: name }]) + .upsert([{ user_id: user_id, story_id: story_id, name: name }]) .select(); if (error) { if (process.env.NODE_ENV !== 'production') { throw new Error( - `An error occured when trying to set user saved stories: ${error.details}`, + `An error occured when trying to set user saved stories: ${JSON.stringify(error)}`, ); } } @@ -105,7 +104,6 @@ export async function deleteUserStoryToReadingList( deleteUserStory(user_id, story_id, SavedList.READING_LIST); } - export async function deleteUserStory( user_id: string | undefined, story_id: number, @@ -127,16 +125,18 @@ export async function deleteUserStory( } } -export async function isStoryInReadingList(storyId: number, userId: string | undefined): Promise { - let { data, error } = await supabase - .rpc('is_story_saved_for_user', { - list_name: "reading list", - story_db_id: storyId, - user_uuid: userId, - }) +export async function isStoryInReadingList( + storyId: number, + userId: string | undefined, +): Promise { + let { data, error } = await supabase.rpc('is_story_saved_for_user', { + list_name: 'reading list', + story_db_id: storyId, + user_uuid: userId, + }); if (error) { - console.error(error) + console.error(error); return false; } diff --git a/src/utils/AuthContext.tsx b/src/utils/AuthContext.tsx index 4273a1cb..34d711da 100644 --- a/src/utils/AuthContext.tsx +++ b/src/utils/AuthContext.tsx @@ -31,13 +31,13 @@ export interface AuthState { resendVerification: (email: string) => Promise; resetPassword: (email: string) => Promise< | { - data: object; - error: null; - } + data: object; + error: null; + } | { - data: null; - error: AuthError; - } + data: null; + error: AuthError; + } >; updateUser: (attributes: UserAttributes) => Promise; signOut: () => Promise; diff --git a/src/utils/PubSubContext.tsx b/src/utils/PubSubContext.tsx new file mode 100644 index 00000000..5bfe68ae --- /dev/null +++ b/src/utils/PubSubContext.tsx @@ -0,0 +1,60 @@ +import React, { + createContext, + useContext, + useMemo, + useState, +} from 'react'; + +export interface PubSubState { + channels: Record; + initializeChannel: (id: number) => void; + publish: (id: number, message: boolean) => void; +} + +const BooleanPubSubContext = createContext({} as PubSubState); + +export function usePubSub() { + const value = useContext(BooleanPubSubContext); + if (process.env.NODE_ENV !== 'production') { + if (!value) { + throw new Error( + 'usePubSub must be wrapped in a ', + ); + } + } + + return value; +} + +export function BooleanPubSubProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [channels, setChannels] = useState>({}) + + const initializeChannel = (id: number) => { + if (!(id in channels)) { + setChannels({ ...channels, [id]: undefined }) + } + } + + const publish = (id: number, message: boolean) => { + setChannels({ ...channels, [id]: message }) + } + + const authContextValue = useMemo( + () => ({ + channels, + initializeChannel, + publish, + }), + [channels], + ); + + return ( + + {children} + + ); +}