From 1016f0cbbdaa5d207199bc4fd706360c65517bf9 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 3 Aug 2024 01:47:07 +0900 Subject: [PATCH] feat: block-users impl (#6) ## Demo https://github.com/user-attachments/assets/48294c62-ada1-4794-905e-407c71a82d49 ## Description 1. **Change Pagination Queries to Cursor Pagination:** - Update all pagination queries in the Supabase database to use cursor pagination instead of offset-based pagination. - Ensure that the new cursor pagination implementation maintains or improves performance and scalability. 2. **Add User Report Feature:** - Implement a feature allowing users to report other users for inappropriate behavior or content. - Design a database schema to store reports, including details such as the reporting user, the reported user, the reason for the report, and the date/time of the report. - Create API endpoints for submitting and retrieving reports. - Ensure proper validation and authentication for the reporting feature. 3. **Add User Block Feature:** - Implement a feature allowing users to block other users. - Design a database schema to store blocked user relationships, including details such as the blocking user, the blocked user, and the date/time of the block. - Create API endpoints for blocking and unblocking users. - Ensure that blocked users cannot interact with or view the content of the users who blocked them. --- app/(app)/(tabs)/index.tsx | 45 ++--- app/(app)/post/[id]/index.tsx | 8 +- app/(app)/post/[id]/replies.tsx | 24 +-- app/(app)/post/[id]/update.tsx | 2 +- app/(app)/settings/block-users.tsx | 181 ++++++++++++++++++ app/(app)/settings/index.tsx | 2 +- app/_layout.tsx | 38 +++- assets/langs/en.json | 11 +- assets/langs/ko.json | 13 +- bun.lockb | Bin 905357 -> 905357 bytes .../20240731165135_block_users/migration.sql | 18 ++ prisma/schema.prisma | 20 +- src/apis/blockQueries.ts | 100 ++++++++++ src/apis/postQueries.ts | 59 +++--- src/apis/replyQueries.ts | 17 +- src/apis/reportQueries.ts | 12 ++ src/components/modals/ReportModal.tsx | 5 +- src/components/modals/common/InputModal.tsx | 161 ++++++++-------- ...rrorFallback.tsx => FallbackComponent.tsx} | 0 src/components/uis/NotFound.tsx | 53 +++-- src/components/uis/PostListItem.tsx | 9 +- src/providers/AppLogicProvider.tsx | 56 +++++- src/providers/ReducerProvider.tsx | 82 -------- src/providers/StateProvider.tsx | 42 ---- src/providers/index.tsx | 23 ++- src/recoil/atoms.ts | 2 + src/types/index.ts | 2 + src/types/supabase.ts | 36 ++++ test/src/providers/AppLogicProvider.test.tsx | 41 ++++ test/src/providers/ReducerProvider.test.tsx | 45 ----- test/src/providers/StateProvider.test.tsx | 60 ------ test/testSetup.ts | 7 + 32 files changed, 752 insertions(+), 422 deletions(-) create mode 100644 app/(app)/settings/block-users.tsx create mode 100644 prisma/migrations/20240731165135_block_users/migration.sql create mode 100644 src/apis/blockQueries.ts create mode 100644 src/apis/reportQueries.ts rename src/components/uis/{ErrorFallback.tsx => FallbackComponent.tsx} (100%) delete mode 100644 src/providers/ReducerProvider.tsx delete mode 100644 src/providers/StateProvider.tsx create mode 100644 test/src/providers/AppLogicProvider.test.tsx delete mode 100644 test/src/providers/ReducerProvider.test.tsx delete mode 100644 test/src/providers/StateProvider.test.tsx diff --git a/app/(app)/(tabs)/index.tsx b/app/(app)/(tabs)/index.tsx index 61af528..4f897c9 100644 --- a/app/(app)/(tabs)/index.tsx +++ b/app/(app)/(tabs)/index.tsx @@ -5,16 +5,17 @@ import {FlashList} from '@shopify/flash-list'; import {useRouter} from 'expo-router'; import {PostWithJoins} from '../../../src/types'; import PostListItem from '../../../src/components/uis/PostListItem'; -import {useEffect, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import {supabase} from '../../../src/supabase'; -import {PAGE_SIZE} from '../../../src/utils/constants'; import { fetchPostById, fetchPostPagination, } from '../../../src/apis/postQueries'; import useSWR from 'swr'; -import FallbackComponent from '../../../src/components/uis/ErrorFallback'; +import FallbackComponent from '../../../src/components/uis/FallbackComponent'; import CustomLoadingIndicator from '../../../src/components/uis/CustomLoadingIndicator'; +import {useRecoilValue} from 'recoil'; +import {authRecoilState} from '../../../src/recoil/atoms'; const Container = styled.View` flex: 1; @@ -24,26 +25,34 @@ const Container = styled.View` export default function Posts(): JSX.Element { const {push} = useRouter(); - const [page, setPage] = useState(0); + const {authId, blockedUserIds} = useRecoilValue(authRecoilState); + const [cursor, setCursor] = useState(undefined); const [allPosts, setAllPosts] = useState([]); const [loadingMore, setLoadingMore] = useState(false); - const fetcher = (page: number) => fetchPostPagination(page, PAGE_SIZE); + const fetcher = useCallback( + (cursor: string | undefined) => + fetchPostPagination({cursor, blockedUserIds}), + [blockedUserIds], + ); const {error, isValidating, mutate} = useSWR( - ['posts', page], - () => fetcher(page), + ['posts', cursor], + () => fetchPostPagination({cursor, blockedUserIds}), { revalidateOnFocus: false, revalidateIfStale: false, revalidateOnReconnect: false, onSuccess: (data) => { - if (page === 0) { + if (!cursor) { setAllPosts(data); } else { setAllPosts((prevPosts) => [...prevPosts, ...data]); } setLoadingMore(false); + if (data.length > 0) { + setCursor(data[data.length - 1].created_at || undefined); + } }, }, ); @@ -96,26 +105,18 @@ export default function Posts(): JSX.Element { }; }, []); - useEffect(() => { - if (page !== 0) { + const loadMore = () => { + if (!loadingMore) { setLoadingMore(true); - fetcher(page).then((newPosts) => { + fetcher(cursor).then((newPosts) => { setAllPosts((prevPosts) => [...prevPosts, ...newPosts]); setLoadingMore(false); }); } - }, [page]); - - const loadMore = () => { - if (!loadingMore) { - setLoadingMore(true); - setPage((prevPage) => prevPage + 1); - } }; const handleRefresh = () => { - setPage(0); - setAllPosts([]); + setCursor(undefined); mutate(); }; @@ -130,13 +131,13 @@ export default function Posts(): JSX.Element { ( like.user_id === item.user_id && like.liked, + (like) => like.user_id === authId && like.liked, ), likeCnt: item.likes?.length || 0, replyCnt: item.replies?.length || 0, diff --git a/app/(app)/post/[id]/index.tsx b/app/(app)/post/[id]/index.tsx index 9deecab..a1797d3 100644 --- a/app/(app)/post/[id]/index.tsx +++ b/app/(app)/post/[id]/index.tsx @@ -5,7 +5,7 @@ import {ReplyWithJoins} from '../../../../src/types'; import useSWR from 'swr'; import CustomLoadingIndicator from '../../../../src/components/uis/CustomLoadingIndicator'; import {t} from '../../../../src/STRINGS'; -import ErrorFallback from '../../../../src/components/uis/ErrorFallback'; +import ErrorFallback from '../../../../src/components/uis/FallbackComponent'; import NotFound from '../../../../src/components/uis/NotFound'; import {Pressable, View} from 'react-native'; import {openURL} from '../../../../src/utils/common'; @@ -97,8 +97,10 @@ export default function PostDetails(): JSX.Element { } else if (post?.user_id) { handlePeerContentAction({ userId: post?.user_id, - onCompleted: async () => { - back(); + onCompleted(type) { + if (type === 'block') { + back(); + } }, }); } diff --git a/app/(app)/post/[id]/replies.tsx b/app/(app)/post/[id]/replies.tsx index 7086c1e..82212b5 100644 --- a/app/(app)/post/[id]/replies.tsx +++ b/app/(app)/post/[id]/replies.tsx @@ -1,7 +1,7 @@ import {useState, useEffect, Ref} from 'react'; import {css} from '@emotion/native'; import {KeyboardAvoidingView, Platform, View} from 'react-native'; -import {HEADER_HEIGHT, PAGE_SIZE} from '../../../../src/utils/constants'; +import {HEADER_HEIGHT} from '../../../../src/utils/constants'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {FlashList} from '@shopify/flash-list'; import {Typography, useDooboo} from 'dooboo-ui'; @@ -23,7 +23,7 @@ import {useRecoilValue} from 'recoil'; import {authRecoilState} from '../../../../src/recoil/atoms'; import useSWR from 'swr'; import {ReplyWithJoins} from '../../../../src/types'; -import FallbackComponent from '../../../../src/components/uis/ErrorFallback'; +import FallbackComponent from '../../../../src/components/uis/FallbackComponent'; import {toggleLike} from '../../../../src/apis/likeQueries'; export default function Replies({ @@ -41,19 +41,18 @@ export default function Replies({ const {authId} = useRecoilValue(authRecoilState); const [reply, setReply] = useState(''); const [assets, setAssets] = useState([]); - const [page, setPage] = useState(0); + const [cursor, setCursor] = useState(undefined); const [replies, setReplies] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const [isCreateReplyInFlight, setIsCreateReplyInFlight] = useState(false); const {error, isValidating, mutate} = useSWR( - ['replies', postId, page], - () => - fetchReplyPagination({page, pageSize: PAGE_SIZE, postId: postId || ''}), + ['replies', postId, cursor], + () => fetchReplyPagination({cursor, postId: postId as string}), { revalidateOnFocus: false, onSuccess: (data) => { - if (page === 0) { + if (cursor === new Date().toISOString()) { setReplies(data); } else { setReplies((prevReplies) => [...prevReplies, ...data]); @@ -166,12 +165,15 @@ export default function Replies({ const loadMoreReplies = () => { if (loadingMore) return; setLoadingMore(true); - setPage((prevPage) => prevPage + 1); + const lastReply = replies[replies.length - 1]; + if (lastReply) { + setCursor(lastReply.created_at || undefined); + } }; const handleRefresh = () => { setReplies([]); - setPage(0); + setCursor(new Date().toISOString()); mutate(); }; @@ -221,10 +223,10 @@ export default function Replies({ }; useEffect(() => { - if (page === 1 && replies.length === 0) { + if (cursor === new Date().toISOString() && replies.length === 0) { mutate(); } - }, [mutate, page, replies.length]); + }, [mutate, cursor, replies.length]); const content = (() => { switch (true) { diff --git a/app/(app)/post/[id]/update.tsx b/app/(app)/post/[id]/update.tsx index e6e4f3e..4257e33 100644 --- a/app/(app)/post/[id]/update.tsx +++ b/app/(app)/post/[id]/update.tsx @@ -10,7 +10,7 @@ import { Platform, Pressable, } from 'react-native'; -import ErrorFallback from '../../../../src/components/uis/ErrorFallback'; +import ErrorFallback from '../../../../src/components/uis/FallbackComponent'; import {useRecoilValue} from 'recoil'; import { getPublicUrlFromPath, diff --git a/app/(app)/settings/block-users.tsx b/app/(app)/settings/block-users.tsx new file mode 100644 index 0000000..2b153ee --- /dev/null +++ b/app/(app)/settings/block-users.tsx @@ -0,0 +1,181 @@ +import {useState, useEffect, useCallback} from 'react'; +import {Platform} from 'react-native'; +import styled, {css} from '@emotion/native'; +import {FlashList} from '@shopify/flash-list'; +import {Button, Typography, useDooboo} from 'dooboo-ui'; +import CustomPressable from 'dooboo-ui/uis/CustomPressable'; +import {Stack} from 'expo-router'; +import {useRecoilState} from 'recoil'; +import UserImage from '../../../src/components/uis/UserImage'; +import {delayPressIn, PAGE_SIZE} from '../../../src/utils/constants'; +import {authRecoilState} from '../../../src/recoil/atoms'; +import NotFound from '../../../src/components/uis/NotFound'; +import {t} from '../../../src/STRINGS'; +import {fetchBlockUsersPagination, fetchUnblockUser} from '../../../src/apis/blockQueries'; +import {User} from '../../../src/types'; + +const Profile = styled.View` + padding: 16px 16px 8px 16px; + flex-direction: row; + align-items: center; + gap: 12px; +`; + +function BlockUserItem({ + imageUrl, + displayName, + onPress, +}: { + imageUrl: string | undefined | null; + displayName: string; + onPress?: () => void; +}): JSX.Element { + const {theme} = useDooboo(); + + return ( + + + + {displayName} + + + + {t('common.unblock')} + + + + ); +} + +const Container = styled.View` + flex: 1; + align-self: stretch; + background-color: ${({theme}) => theme.bg.basic}; +`; + +export default function BlockUser(): JSX.Element { + const {alertDialog} = useDooboo(); + const [{authId}, setAuth] = useRecoilState(authRecoilState); + const [blockUsers, setBlockUsers] = useState([]); + const [cursor, setCursor] = useState(undefined); + const [loadingMore, setLoadingMore] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const loadBlockedUsers = useCallback( + async (loadMore = false) => { + if (loadingMore || refreshing || !authId) return; + + if (loadMore) { + setLoadingMore(true); + } else { + setRefreshing(true); + setCursor(new Date().toISOString()); + } + + try { + const data = await fetchBlockUsersPagination( + authId, + loadMore ? cursor : new Date().toISOString(), + PAGE_SIZE, + ); + + setBlockUsers((prev) => (loadMore ? [...prev, ...data] : data)); + + if (data.length > 0) { + setCursor(data[data.length - 1].created_at || undefined); + } + } catch (error) { + console.error('Failed to fetch blocked users:', error); + } finally { + setLoadingMore(false); + setRefreshing(false); + } + }, + [authId, cursor, loadingMore, refreshing], + ); + + useEffect(() => { + if (authId) { + loadBlockedUsers(); + } + }, [authId, loadBlockedUsers]); + + const handleUnblock = (userId: string | undefined): void => { + if (!userId || !authId) { + return; + } + + alertDialog.open({ + title: t('blockUsers.cancelBlock'), + body: t('blockUsers.cancelBlockDesc'), + closeOnTouchOutside: false, + actions: [ +