Skip to content

Commit

Permalink
feat: block-users impl (#6)
Browse files Browse the repository at this point in the history
## 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.
  • Loading branch information
hyochan authored Aug 2, 2024
1 parent 329145e commit 1016f0c
Show file tree
Hide file tree
Showing 32 changed files with 752 additions and 422 deletions.
45 changes: 23 additions & 22 deletions app/(app)/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string | undefined>(undefined);
const [allPosts, setAllPosts] = useState<PostWithJoins[]>([]);
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);
}
},
},
);
Expand Down Expand Up @@ -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();
};

Expand All @@ -130,13 +131,13 @@ export default function Posts(): JSX.Element {
<FlashList
data={allPosts}
onRefresh={handleRefresh}
refreshing={isValidating && page === 0}
refreshing={isValidating && cursor === null}
renderItem={({item}) => (
<PostListItem
post={item}
controlItemProps={{
hasLiked: item.likes?.some(
(like) => 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,
Expand Down
8 changes: 5 additions & 3 deletions app/(app)/post/[id]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}
},
});
}
Expand Down
24 changes: 13 additions & 11 deletions app/(app)/post/[id]/replies.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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({
Expand All @@ -41,19 +41,18 @@ export default function Replies({
const {authId} = useRecoilValue(authRecoilState);
const [reply, setReply] = useState('');
const [assets, setAssets] = useState<ImagePickerAsset[]>([]);
const [page, setPage] = useState(0);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [replies, setReplies] = useState<ReplyWithJoins[]>([]);
const [loadingMore, setLoadingMore] = useState(false);
const [isCreateReplyInFlight, setIsCreateReplyInFlight] = useState(false);

const {error, isValidating, mutate} = useSWR<ReplyWithJoins[]>(
['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]);
Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/post/[id]/update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
181 changes: 181 additions & 0 deletions app/(app)/settings/block-users.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Profile>
<UserImage height={48} imageUrl={imageUrl} width={48} />
<Typography.Body2
style={css`
font-family: Pretendard-Bold;
`}
>
{displayName}
</Typography.Body2>
<CustomPressable
delayHoverIn={delayPressIn}
onPress={onPress}
style={css`
margin-left: auto;
background-color: ${theme.button.light.bg};
padding: 4px 8px;
border-radius: 18px;
`}
>
<Typography.Body4
style={css`
font-family: Pretendard-Bold;
`}
>
{t('common.unblock')}
</Typography.Body4>
</CustomPressable>
</Profile>
);
}

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<User[]>([]);
const [cursor, setCursor] = useState<string | undefined>(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: [
<Button
color="light"
key="button-light"
onPress={() => alertDialog.close()}
styles={{
container: css`
height: 48px;
`,
}}
text={t('common.cancel')}
/>,
<Button
onPress={() => {
alertDialog.close();

fetchUnblockUser(authId, userId);
setAuth((prev) => ({...prev, blockedUserIds: prev.blockedUserIds.filter((id) => id !== userId)}));
}}
styles={{
container: css`
height: 48px;
`,
}}
text={t('common.unblock')}
/>,
],
});
};

return (
<Container>
<Stack.Screen options={{title: t('blockUsers.title')}} />
<FlashList
ListEmptyComponent={<NotFound />}
data={blockUsers}
estimatedItemSize={50}
onEndReached={() => loadBlockedUsers(true)}
onEndReachedThreshold={0.1}
onRefresh={() => loadBlockedUsers()}
refreshing={refreshing}
renderItem={({item}) => (
<BlockUserItem
displayName={item?.name || t('common.unknown')}
imageUrl={item?.avatar_url}
onPress={() => handleUnblock(item?.id)}
/>
)}
showsVerticalScrollIndicator={Platform.OS === 'web'}
/>
</Container>
);
}
Loading

0 comments on commit 1016f0c

Please sign in to comment.