diff --git a/src/App.tsx b/src/App.tsx index a1ad81f..abf4aac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,6 @@ -import styled from '@emotion/native'; import { DefaultTheme, NavigationContainer } from '@react-navigation/native'; -import { useState } from 'react'; import { AppProviders } from '~providers/AppProviders'; import { lightTheme } from '~styles/theme'; -import StoryBookUI from '../.storybook'; import { RootNavigator } from '~navigation/RootNavigator'; import { useWebSocket } from '~hooks/useWebSocket'; import { WebSocketProvider } from '~providers/WebSocketProvider'; @@ -35,40 +32,41 @@ const MainApp = () => { export const App = () => { // const { isMswEnabled } = useInitializeMsw(); - const [storybookEnabled, setStorybookEnabled] = useState(false); + // const [storybookEnabled, setStorybookEnabled] = useState(false); // if (__DEV__ && !isMswEnabled) { // return Loading MSW...; // } - const toggleStorybook = () => setStorybookEnabled(prev => !prev); + // const toggleStorybook = () => setStorybookEnabled(prev => !prev); return ( <> - {__DEV__ && ( + {/* {__DEV__ && ( S )} - {__DEV__ && storybookEnabled ? : } + {__DEV__ && storybookEnabled ? : } */} + ); }; -const StoryBookFloatingButton = styled.TouchableOpacity` - position: absolute; - right: 30px; - bottom: 30px; - z-index: 1000; - width: 60px; - height: 60px; - border-radius: 30px; - background-color: purple; - align-items: center; - justify-content: center; -`; +// const StoryBookFloatingButton = styled.TouchableOpacity` +// position: absolute; +// right: 30px; +// bottom: 30px; +// z-index: 1000; +// width: 60px; +// height: 60px; +// border-radius: 30px; +// background-color: purple; +// align-items: center; +// justify-content: center; +// `; -const StoryBookButtonText = styled.Text` - color: white; - font-size: 24px; -`; +// const StoryBookButtonText = styled.Text` +// color: white; +// font-size: 24px; +// `; diff --git a/src/apis/api.ts b/src/apis/api.ts index e66475a..1efa4bf 100644 --- a/src/apis/api.ts +++ b/src/apis/api.ts @@ -12,15 +12,7 @@ export const api = ky.create({ async request => { const accessToken = await getAccessToken(); if (accessToken) { - request.headers.set( - 'Authorization', - // accessToken, - // `Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInByb3ZpZGVyIjoiS0FLQU8iLCJleHAiOjE3Mzk3NjQwMDIsImVtYWlsIjoibWtoNjc5M0BuYXZlci5jb20ifQ.EF03NpevMSZ2DcM5Q-trEEmRa0KEb5HpJ1HlD-Vj8xy3N2JoFvdQFoWDJRM3IGVwx58L9T2oV7GBTr6wJOevnA`, - // 패밀리장, 가족많음 - // `Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInByb3ZpZGVyIjoiR09PR0xFIiwiZXhwIjoxNzQwMjQ4MjA3LCJlbWFpbCI6ImJibGJibGFuNjlAZ21haWwuY29tIn0.CpauBw9_yXlYQjr-BZP7xqm1u63pj1g1aM3kX9HwCm37BMhpOQGz1Mq8R42CihtC8henTRy0OHaxa7q9-1Svzw`, - //나 혼자 패밀리장 - `Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJBY2Nlc3NUb2tlbiIsInByb3ZpZGVyIjoiS0FLQU8iLCJleHAiOjE3NDEyNzUwNjcsImVtYWlsIjoibndpNjk1OUBnbWFpbC5jb20ifQ.pOk3HSBSFGPKGIL4KS7tbwCrzPfCloQZrA4xWzZVpngYTnodSZuenuoUYRC1DkWY-EdmvK-cv_am2EnPryhsxg`, - ); + request.headers.set('Authorization', `Bearer ${accessToken}`); } }, ], diff --git a/src/apis/block/blockUser.ts b/src/apis/block/blockUser.ts new file mode 100644 index 0000000..7a046b7 --- /dev/null +++ b/src/apis/block/blockUser.ts @@ -0,0 +1,23 @@ +import { HTTPError } from 'ky'; +import { api } from '~apis/api'; +import { APIResponse } from '~types/api'; + +export interface ResponseBlockUser { + blockId: number; + blockerMemberId: number; + blockedMemberId: number; + blockedMemberName: string; +} + +export const blockUser = async (blockedId: number): Promise> => { + try { + const response = await api.post(`block/${blockedId}`).json>(); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = await error.response.json(); + console.error(errorData); + } + throw error; + } +}; diff --git a/src/apis/block/fetchBlockedUsers.ts b/src/apis/block/fetchBlockedUsers.ts new file mode 100644 index 0000000..c8ca93c --- /dev/null +++ b/src/apis/block/fetchBlockedUsers.ts @@ -0,0 +1,52 @@ +import { HTTPError } from 'ky'; +import { api } from '~apis/api'; +import { APIResponse } from '~types/api'; +import { FamilyRole } from '~types/family-role'; +import { Gender } from '~types/gender'; + +export interface BlockedUser { + blockId: number; + blockedMemberName: string; + memberGender: Gender; + familyRole: FamilyRole; +} + +interface Sort { + unsorted: boolean; + sorted: boolean; + empty: boolean; +} + +interface Pageable { + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; + offset: number; + sort: Sort; +} + +export interface ResponseBlockList { + pageable: Pageable; + numberOfElements: number; + size: number; + content: BlockedUser[]; + number: number; + sort: Sort; + first: boolean; + last: boolean; + empty: boolean; +} + +export const fetchBlockedUsers = async (page = 0): Promise> => { + try { + const response = await api.get(`block/list?page=${page}`).json>(); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = error.response.json(); + console.error(errorData); + } + throw error; + } +}; diff --git a/src/apis/block/unblockUser.ts b/src/apis/block/unblockUser.ts new file mode 100644 index 0000000..6f0424a --- /dev/null +++ b/src/apis/block/unblockUser.ts @@ -0,0 +1,16 @@ +import { HTTPError } from 'ky'; +import { api } from '~apis/api'; +import { APIResponse } from '~types/api'; + +export const unblockUser = async (blockId: number): Promise> => { + try { + const response = await api.delete(`block/${blockId}`).json>(); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = await error.response.json(); + console.error(errorData); + } + throw error; + } +}; diff --git a/src/apis/block/useBlock.ts b/src/apis/block/useBlock.ts new file mode 100644 index 0000000..56516d3 --- /dev/null +++ b/src/apis/block/useBlock.ts @@ -0,0 +1,47 @@ +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { blockUser } from '~apis/block/blockUser'; +import { UseMutationCustomOptions } from '~types/api'; +import { unblockUser } from './unblockUser'; +import { BlockedUser, fetchBlockedUsers } from '~apis/block/fetchBlockedUsers'; + +const useBlockedUserList = () => { + return useSuspenseQuery({ + queryKey: ['allBlockedUsers'], + queryFn: async () => { + let allData: BlockedUser[] = []; + let currentPage = 0; + let isLastPage = false; + + while (!isLastPage) { + const response = await fetchBlockedUsers(currentPage); + allData = [...allData, ...response.data.content]; + isLastPage = response.data.last; + currentPage += 1; + } + + return allData; + }, + }); +}; + +const useBlockUser = (mutationOptions?: UseMutationCustomOptions) => { + return useMutation({ + mutationFn: (blockedId: number) => blockUser(blockedId), + ...mutationOptions, + }); +}; + +const useUnblockUser = (mutationOptions?: UseMutationCustomOptions) => { + return useMutation({ + mutationFn: (blockId: number) => unblockUser(blockId), + ...mutationOptions, + }); +}; + +export const useBlock = () => { + const blockedUsers = useBlockedUserList().data; + const blockUserMutation = useBlockUser(); + const unblockUserMutation = useUnblockUser(); + + return { blockedUsers, blockUserMutation, unblockUserMutation }; +}; diff --git a/src/apis/chat/fetchChatMessages.ts b/src/apis/chat/fetchChatMessages.ts new file mode 100644 index 0000000..8794236 --- /dev/null +++ b/src/apis/chat/fetchChatMessages.ts @@ -0,0 +1,73 @@ +import { HTTPError } from 'ky'; +import { api } from '~apis/api'; +import { APIResponse } from '~types/api'; +import { BooleanString } from '~types/boolean-string'; +import { FamilyRole } from '~types/family-role'; +import { Gender } from '~types/gender'; + +interface Sort { + unsorted: boolean; + sorted: boolean; + empty: boolean; +} + +interface Pageble { + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; + offset: number; + sort: Sort; +} + +interface MemberInfo { + memberId: number; + memberName: string; + email: string; + memberGender: Gender; + familyRole: FamilyRole; + memberProfileImg: number; +} + +interface ChatContent { + chatId: number; + createdAt: string; + updatedAt: string; + chatRoomId: number; + memberInfo: MemberInfo; + chatType: string; + isRead: BooleanString; + text: string; +} + +export interface ResponseFetchChatMessages { + pageable: Pageble; + numberOfElements: number; + size: number; + content: ChatContent[]; + number: number; + sort: Sort; + first: boolean; + last: boolean; + empty: boolean; +} + +export const fetchChatMessages = async ( + chatRoomId: number, + lastMessageCreatedAt: string, +): Promise> => { + try { + const response = await api + .get(`chat/message/${chatRoomId}`, { + json: { chatRoomId, lastMessageCreatedAt }, + }) + .json>(); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = await error.response.json(); + console.error(errorData); + } + throw error; + } +}; diff --git a/src/apis/chat/fetchChatRooms.ts b/src/apis/chat/fetchChatRooms.ts index 1df9523..0d01cb6 100644 --- a/src/apis/chat/fetchChatRooms.ts +++ b/src/apis/chat/fetchChatRooms.ts @@ -4,25 +4,25 @@ import { AvatarNumber } from '~types/avatar-number'; import { FamilyRole } from '~types/family-role'; import { Gender } from '~types/gender'; -export type FetchChatRoomsResponseType = { +interface Member { + memberId: number; + memberName: string; + email: string; + memberGender: Gender; + familyRole: FamilyRole; + memberProfileImg: AvatarNumber; +} +export interface ResponseFetchChatRoom { chatRoomId: number; name: string; lastMessage: string; unreadMessageCount: number; - members: [ - { - memberId: number; - memberName: string; - email: string; - memberGender: Gender; - familyRole: FamilyRole; - memberProfileImg: AvatarNumber; - }, - ]; -}[]; -export const fetchChatRooms = async (): Promise> => { + members: Member[]; +} + +export const fetchChatRooms = async (): Promise> => { try { - const response = await api.get('chat/rooms').json>(); + const response = await api.get('chat/rooms').json>(); return response; } catch (error) { console.error('Error:', error); diff --git a/src/apis/chat/useChatMessages.ts b/src/apis/chat/useChatMessages.ts new file mode 100644 index 0000000..85109b9 --- /dev/null +++ b/src/apis/chat/useChatMessages.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchChatMessages } from '~apis/chat/fetchChatMessages'; +import { UseQueryCustomOptions } from '~types/api'; + +export const useChatMessages = ( + chatRoomId: number, + lastMessageCreatedAt: string, + queryOptions?: UseQueryCustomOptions, +) => { + return useQuery({ + queryKey: ['chatMessages', chatRoomId, lastMessageCreatedAt], + queryFn: ({ queryKey }) => { + const [, chatRoomId, lastMessageCreatedAt] = queryKey; + return fetchChatMessages(chatRoomId as number, lastMessageCreatedAt as string); + }, + ...queryOptions, + }); +}; diff --git a/src/apis/dog/createDog.ts b/src/apis/dog/createDog.ts index 8984a1f..76c8e77 100644 --- a/src/apis/dog/createDog.ts +++ b/src/apis/dog/createDog.ts @@ -21,7 +21,6 @@ export const createDog = async (dogProfile: DogProfileType): Promise> => { try { - const response = await api.get(`dogs/${dogId}/walks`).json>(); + const response = await api + .get(`member/walk-info/${memberId}`) + .json>(); return response; } catch (error) { console.error('Error:', error); diff --git a/src/apis/dog/useAccumulatedWalkInfo.ts b/src/apis/dog/useAccumulatedWalkInfo.ts index 02fe972..657fc66 100644 --- a/src/apis/dog/useAccumulatedWalkInfo.ts +++ b/src/apis/dog/useAccumulatedWalkInfo.ts @@ -2,12 +2,12 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { fetchAccumulatedWalkInfo } from '~apis/dog/fetchAccumulatedWalkInfo'; interface useAccumulatedWalkInfoProps { - dogId: number; + memberId: number; } -export const useAccumulatedWalkInfo = ({ dogId }: useAccumulatedWalkInfoProps) => { +export const useAccumulatedWalkInfo = ({ memberId }: useAccumulatedWalkInfoProps) => { const { data: accumulatedWalkInfo } = useSuspenseQuery({ - queryKey: ['accumulatedWalkInfo', dogId], - queryFn: () => fetchAccumulatedWalkInfo({ dogId }), + queryKey: ['accumulatedWalkInfo', memberId], + queryFn: () => fetchAccumulatedWalkInfo({ memberId }), select: ({ data }) => data, }); return accumulatedWalkInfo; diff --git a/src/apis/friend/deleteFriend.ts b/src/apis/friend/deleteFriend.ts new file mode 100644 index 0000000..423f1c7 --- /dev/null +++ b/src/apis/friend/deleteFriend.ts @@ -0,0 +1,16 @@ +import { HTTPError } from 'ky'; +import { api } from '~apis/api'; +import { APIResponse } from '~types/api'; + +export const deleteFriend = async (memberId: number): Promise> => { + try { + const response = await api.delete(`friend/${memberId}`).json>(); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = await error.response.json(); + console.error(errorData); + } + throw error; + } +}; diff --git a/src/apis/friend/respondToFriendRequest.ts b/src/apis/friend/respondToFriendRequest.ts new file mode 100644 index 0000000..01d1b36 --- /dev/null +++ b/src/apis/friend/respondToFriendRequest.ts @@ -0,0 +1,37 @@ +import { HTTPError } from 'ky'; +import { api } from '~apis/api'; +import { APIResponse } from '~types/api'; +import { FamilyRole } from '~types/family-role'; +import { Gender } from '~types/gender'; + +interface ResponseFreindRequestAction { + memberId: number; + memberName: string; + email: string; + provider: string; + memberGender: Gender; + memberBirthDate: string; + address: string; + familyRole: FamilyRole; + memberProfileImg: number; +} + +export const respondToFriendRequest = async ( + memberId: number, + decision: 'ACCEPT' | 'DENY', +): Promise> => { + try { + const response = await api + .post('friend', { + json: { memberId, decision }, + }) + .json>(); + return response; + } catch (error) { + if (error instanceof HTTPError) { + const errorData = await error.response.json(); + console.error(errorData); + } + throw error; + } +}; diff --git a/src/apis/friend/useDeleteFriend.ts b/src/apis/friend/useDeleteFriend.ts new file mode 100644 index 0000000..d44a65f --- /dev/null +++ b/src/apis/friend/useDeleteFriend.ts @@ -0,0 +1,17 @@ +import { useMutation } from '@tanstack/react-query'; +import { deleteFriend } from '~apis/friend/deleteFriend'; +import { useToast } from '~hooks/useToast'; +import { queryClient } from '~providers/QueryClientProvider'; +import { UseMutationCustomOptions } from '~types/api'; + +export const useDeleteFriend = (mutationOptions?: UseMutationCustomOptions) => { + const { successToast } = useToast(); + return useMutation({ + mutationFn: (memberId: number) => deleteFriend(memberId), + onSuccess: () => { + successToast('친구목록에서 삭제되었습니다.'); + queryClient.invalidateQueries({ queryKey: ['friends'] }); + }, + ...mutationOptions, + }); +}; diff --git a/src/apis/friend/useFriends.ts b/src/apis/friend/useFriends.ts index 97a01a4..f3116e1 100644 --- a/src/apis/friend/useFriends.ts +++ b/src/apis/friend/useFriends.ts @@ -1,10 +1,11 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { fetchFriends } from '~apis/friend/fetchFriends'; export const useFriends = () => { const { data } = useQuery({ queryKey: ['friends'], queryFn: fetchFriends, + placeholderData: keepPreviousData, }); return data?.data; }; diff --git a/src/apis/friend/useRespondToFriendRequest.ts b/src/apis/friend/useRespondToFriendRequest.ts new file mode 100644 index 0000000..11b3f14 --- /dev/null +++ b/src/apis/friend/useRespondToFriendRequest.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; +import { respondToFriendRequest } from '~apis/friend/respondToFriendRequest'; +import { UseMutationCustomOptions } from '~types/api'; + +export const useRespondToFriendRequest = (mutationOptions?: UseMutationCustomOptions) => { + return useMutation({ + mutationFn: ({ memberId, decision }: { memberId: number; decision: 'ACCEPT' | 'DENY' }) => + respondToFriendRequest(memberId, decision), + ...mutationOptions, + }); +}; diff --git a/src/apis/member/fetchUserById.ts b/src/apis/member/fetchUserById.ts index 3e131d6..f3cae76 100644 --- a/src/apis/member/fetchUserById.ts +++ b/src/apis/member/fetchUserById.ts @@ -14,17 +14,15 @@ export type FetchUserByIdResponseType = { email: string; address: string; memberGender: Gender; + memberBirthDate: string; familyRole: FamilyRole; - memberProfileImg: string; - avatarNumber: AvatarNumber; + memberProfileImg: AvatarNumber; }; -export const fetchUserById = async ({ - memberId, -}: FetchUserByIdRequestType): Promise> => { +export const fetchUserById = async ({ memberId }: FetchUserByIdRequestType): Promise => { try { const response = await api.get(`member/${memberId}`).json>(); - return response; + return response.data; } catch (error) { console.error('Error:', error); throw error; diff --git a/src/apis/member/useUserById.ts b/src/apis/member/useUserById.ts index 67f225d..6014145 100644 --- a/src/apis/member/useUserById.ts +++ b/src/apis/member/useUserById.ts @@ -1,12 +1,10 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; -import { fetchUserById, FetchUserByIdRequestType } from '~apis/member/fetchUserById'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { fetchUserById, FetchUserByIdRequestType, FetchUserByIdResponseType } from '~apis/member/fetchUserById'; export const useUserById = ({ memberId }: FetchUserByIdRequestType) => { - const { data: userInfo } = useSuspenseQuery({ + return useQuery({ queryKey: ['userInfoById', memberId], queryFn: () => fetchUserById({ memberId }), - select: ({ data }) => data, + placeholderData: keepPreviousData, }); - - return userInfo; }; diff --git a/src/assets/icons/extra-option.svg b/src/assets/icons/extra-option.svg new file mode 100644 index 0000000..aafd699 --- /dev/null +++ b/src/assets/icons/extra-option.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/Common/CompoundOptions/index.tsx b/src/components/Common/CompoundOptions/index.tsx index 4c0a0ec..e695b40 100644 --- a/src/components/Common/CompoundOptions/index.tsx +++ b/src/components/Common/CompoundOptions/index.tsx @@ -1,9 +1,11 @@ -import { PropsWithChildren, ReactNode, createContext, useContext, useEffect, useRef } from 'react'; +import { PropsWithChildren, ReactNode, createContext, useContext, useEffect, useRef, useState } from 'react'; import { Animated, GestureResponderEvent, Modal, ModalProps, PressableProps, StyleSheet } from 'react-native'; import * as S from './styles'; interface OptionContextValue { onClickOutSide?: (event: GestureResponderEvent) => void; + isVisible: boolean; + setShowModal: React.Dispatch>; } const OptionContext = createContext(undefined); @@ -15,15 +17,21 @@ interface OptionMainProps extends ModalProps { } const OptionMain = ({ children, isVisible, hideOption, ...props }: OptionMainProps) => { + const [showModal, setShowModal] = useState(isVisible); const onClickOutSide = (event: GestureResponderEvent) => { if (event.target === event.currentTarget) { hideOption(); } }; + useEffect(() => { + if (isVisible) { + setShowModal(true); + } + }, [isVisible]); return ( - - {children} + + {children} ); }; @@ -37,13 +45,31 @@ const Background = ({ children }: PropsWithChildren) => { const Container = ({ children }: PropsWithChildren) => { const slideAnim = useRef(new Animated.Value(300)).current; - useEffect(() => { + const optionContext = useContext(OptionContext); + if (!optionContext) { + throw new Error('Container must be used within an OptionMain'); + } + + const { isVisible, setShowModal } = optionContext; + + +useEffect(() => { + if (isVisible) { + setShowModal(true); Animated.timing(slideAnim, { toValue: 0, - duration: 300, + duration: 200, useNativeDriver: true, }).start(); - }, [slideAnim]); + } else { + Animated.timing(slideAnim, { + toValue: 600, + duration: 200, + useNativeDriver: true, + }).start(() => setShowModal(false)); + } +}, [isVisible, setShowModal, slideAnim]); + return ( ` +export const OptionText = styled(TextRegular)<{ isDanger: boolean }>` font-size: 17px; color: ${props => (props.isDanger ? 'red' : 'black')}; `; @@ -25,56 +25,12 @@ export const TitleContainer = styled.View` padding: 15px; `; -export const TitleText = styled(TextSemiBold)` +export const TitleText = styled(TextRegular)` font-size: 16px; `; export const Divider = styled.View` width: 100%; height: 1px; - background-color: black; + background-color: ${props => props.theme.colors.gc_2}; `; - -// optionBackground: { -// flex: 1, -// justifyContent: 'flex-end', -// backgroundColor: 'rgba(0,0,0/0.5)', -// }, -// optionContainer: { -// borderRadius: 15, -// marginHorizontal: 10, -// marginBottom: 10, -// backgroundColor: 'white', -// overflow: 'hidden', -// }, -// optionButton: { -// flexDirection: 'row', -// alignItems: 'center', -// justifyContent: 'center', -// height: 50, -// gap: 5, -// }, -// optionButtonPressed: { -// backgroundColor: '#F1F1F5', -// }, -// optionText: { -// fontSize: 17, -// color: 'black', -// fontWeight: '500', -// }, -// dangerText: { -// color: 'red', -// }, -// titleContainer: { -// alignItems: 'center', -// padding: 15, -// }, -// titleText: { -// fontSize: 16, -// fontWeight: '500', -// color: 'black', -// }, -// border: { -// borderBottomColor: 'gray', -// borderBottomWidth: 1, -// }, diff --git a/src/components/Common/Icons/index.tsx b/src/components/Common/Icons/index.tsx index 64d40f4..8838231 100644 --- a/src/components/Common/Icons/index.tsx +++ b/src/components/Common/Icons/index.tsx @@ -34,6 +34,7 @@ import FamilyJoinGuide from '~assets/family-join-guide.svg'; import FamilyJoinGuide2 from '~assets/family-join-guide2.svg'; import FamilyJoinGuide3 from '~assets/family-join-guide3.svg'; import DogTurnedBack from '~assets/dogs/dog-turned-back.svg'; +import FriendOption from '~assets/icons/extra-option.svg'; import Crown from '~assets/crown.svg'; export const Icon = { @@ -72,5 +73,6 @@ export const Icon = { FamilyJoinGuide2: (props: SvgProps) => , FamilyJoinGuide3: (props: SvgProps) => , DogTurnedBack: (props: SvgProps) => , + FriendOption: (props: SvgProps) => , Crown: (props: SvgProps) => , }; diff --git a/src/components/Common/Profile/index.tsx b/src/components/Common/Profile/index.tsx index b770fb1..6a4e03f 100644 --- a/src/components/Common/Profile/index.tsx +++ b/src/components/Common/Profile/index.tsx @@ -39,15 +39,18 @@ export const Profile = ({ size, src, userId, testID, avatarNumber, onPress }: Pr ); } - if (!src) { + if (!src && !avatarNumber) { + console.log(src, avatarNumber); throw new Error('Profile 컴포넌트의 props가 적절하지 않습니다. src, avatarNumber 중 하나는 작성해 주세요.'); } if (typeof src === 'string') { return ; } - const SvgComponent = src; - return ; + if (src) { + const SvgComponent = src; + return ; + } }; return ( diff --git a/src/components/Common/UserInfo/index.tsx b/src/components/Common/UserInfo/index.tsx index eb69dee..ddcc210 100644 --- a/src/components/Common/UserInfo/index.tsx +++ b/src/components/Common/UserInfo/index.tsx @@ -1,22 +1,24 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useContext } from 'react'; import { Profile } from '~components/Common/Profile'; import { Separator } from '~components/Common/Seperator'; import { FamilyRole } from '~types/family-role'; import { Gender } from '~types/gender'; -import { getKoreanRole } from '~utils/getKoreanRoleWithName'; import * as S from './styles'; import { AvatarNumber } from '~types/avatar-number'; +import { FAMILY_ROLE } from '~constants/family-role'; +import { Icon } from '~components/Common/Icons'; +import { FriendOptionContext } from '~components/Social/Friend'; export interface UserItemProps { name: string; gender: Gender; - dogGender: Gender; familyRole: FamilyRole; buttonText: string; isLast?: boolean; avatarNumber: AvatarNumber; onPressButton: () => void; userId: number; + optionButton?: boolean; } export const UserInfo = ({ children }: PropsWithChildren) => { @@ -30,11 +32,20 @@ const Item = ({ name, isLast = false, onPressButton, - dogGender, avatarNumber, userId, + optionButton, }: UserItemProps) => { - //! 유저 강아지 성별 + const friendOptionContext = useContext(FriendOptionContext); + + const handlePressFriendOption = () => { + if (friendOptionContext) { + const { setIsFriendOptionsVisible, setFriendId } = friendOptionContext; + setIsFriendOptionsVisible(true); + setFriendId(userId); + } + }; + return ( @@ -45,13 +56,20 @@ const Item = ({ {gender === 'MALE' ? '남자' : '여자'} - {getKoreanRole({ dogGender, familyRole })} + {FAMILY_ROLE[familyRole]} - - {buttonText} - + + + {buttonText} + + {optionButton && ( + + + + )} + ); diff --git a/src/components/Common/UserInfo/styles.ts b/src/components/Common/UserInfo/styles.ts index a384af2..dbd8692 100644 --- a/src/components/Common/UserInfo/styles.ts +++ b/src/components/Common/UserInfo/styles.ts @@ -41,3 +41,14 @@ export const Button = styled.Pressable` export const ButtonText = styled(TextBold)` text-align: center; `; + +export const RightContainer = styled.View` + flex-direction: row; + gap: 12px; +`; + +export const FriendOptionButton = styled.Pressable` + margin-right: 10px; + justify-content: center; + align-items: center; +`; diff --git a/src/components/Login/LoginButton/index.tsx b/src/components/Login/LoginButton/index.tsx index 340d7a3..2b76c3e 100644 --- a/src/components/Login/LoginButton/index.tsx +++ b/src/components/Login/LoginButton/index.tsx @@ -1,4 +1,5 @@ import { Icon } from '~components/Common/Icons'; +import { AuthNavigations } from '~constants/navigations'; export const SOCIAL_LOGIN_BUTTONS = [ { @@ -6,13 +7,13 @@ export const SOCIAL_LOGIN_BUTTONS = [ textColor: '#000000', IconComponent: Icon.Kakao, text: '카카오계정 로그인', - onPress: (navigation: any) => navigation.navigate('KakaoLogin'), + onPress: (navigation: any) => navigation.navigate(AuthNavigations.KAKAO_LOGIN), }, { backgroundColor: '#F2F2F2', textColor: '#000000', IconComponent: Icon.Google, text: '구글계정 로그인', - onPress: (navigation: any) => navigation.navigate('GoogleLogin'), + onPress: (navigation: any) => navigation.navigate(AuthNavigations.GOOGLE_LOGIN), }, ]; diff --git a/src/components/MyPage/Block/BlockedUsers/index.tsx b/src/components/MyPage/Block/BlockedUsers/index.tsx index 7b6fbce..cd5f043 100644 --- a/src/components/MyPage/Block/BlockedUsers/index.tsx +++ b/src/components/MyPage/Block/BlockedUsers/index.tsx @@ -1,25 +1,52 @@ -import { useBlockedUsers } from '~apis/member/useBlockedUsers'; +import { Alert } from 'react-native'; +import { useBlock } from '~apis/block/useBlock'; import { UserInfo } from '~components/Common/UserInfo'; +import { useToast } from '~hooks/useToast'; +import { queryClient } from '~providers/QueryClientProvider'; +import { NoBlockedUsers } from '~components/MyPage/NoBlockedUsers'; export const BlockedUsers = () => { - const blockedUsers = useBlockedUsers(); + const { blockedUsers, unblockUserMutation } = useBlock(); + const { successToast } = useToast(); + + const handleUnblock = (id: number) => { + Alert.alert('차단 해제하시겠습니까?', '', [ + { + text: '취소', + style: 'cancel', + }, + { + text: '차단 해제', + onPress: () => + unblockUserMutation.mutate(id, { + onSuccess: () => { + successToast('차단이 해제되었습니다'); + queryClient.invalidateQueries({ queryKey: ['allBlockedUsers'] }); + }, + }), + }, + ]); + }; return ( - - {blockedUsers?.map((user, idx) => ( - {}} - userId={user.memberId} - isLast={idx === blockedUsers.length - 1} - /> - ))} + + {blockedUsers.length === 0 ? ( + + ) : ( + blockedUsers.map((user, idx) => ( + handleUnblock(user.blockId)} + userId={user.blockId} + isLast={idx === blockedUsers.length - 1} + /> + )) + )} ); }; diff --git a/src/components/MyPage/NoBlockedUsers/index.tsx b/src/components/MyPage/NoBlockedUsers/index.tsx new file mode 100644 index 0000000..0bdf5fc --- /dev/null +++ b/src/components/MyPage/NoBlockedUsers/index.tsx @@ -0,0 +1,9 @@ +import * as S from './styles'; + +export const NoBlockedUsers = () => { + return ( + + 차단 목록이 없습니다. + + ); +}; diff --git a/src/components/MyPage/NoBlockedUsers/styles.ts b/src/components/MyPage/NoBlockedUsers/styles.ts new file mode 100644 index 0000000..c9b046d --- /dev/null +++ b/src/components/MyPage/NoBlockedUsers/styles.ts @@ -0,0 +1,12 @@ +import styled from '@emotion/native'; +import { TextRegular } from '~components/Common/Text'; + +export const NoBlockedUsers = styled.View` + flex: 1; + justify-content: center; + align-items: center; +`; + +export const Description = styled(TextRegular)` + color: ${props => props.theme.colors.font_1}; +`; diff --git a/src/components/Profile/UserProfile/index.tsx b/src/components/Profile/UserProfile/index.tsx index c78a3b9..b58f46f 100644 --- a/src/components/Profile/UserProfile/index.tsx +++ b/src/components/Profile/UserProfile/index.tsx @@ -9,18 +9,21 @@ interface UserProfileProps { } export const UserProfile = ({ userId }: UserProfileProps) => { - const { address, familyRole, avatarNumber, memberGender, memberName } = useUserById({ memberId: userId }); + const { data: user, isPending, isError } = useUserById({ memberId: userId }); + + if (isPending || isError) { + return <>; + } return ( - - {memberName} - {address} + + {user.memberName} + {user.address} - {memberGender === 'MALE' ? '남자' : '여자'} + {user.memberGender === 'MALE' ? '남자' : '여자'} - {/* 임시 dogGender */} - {getKoreanRole({ dogGender: 'FEMALE', familyRole })} + {getKoreanRole({ dogGender: 'FEMALE', familyRole: user.familyRole })} ); diff --git a/src/components/Profile/WalkInfo/index.tsx b/src/components/Profile/WalkInfo/index.tsx index 8a2ec89..1ec2f97 100644 --- a/src/components/Profile/WalkInfo/index.tsx +++ b/src/components/Profile/WalkInfo/index.tsx @@ -1,8 +1,8 @@ import { useAccumulatedWalkInfo } from '~apis/dog/useAccumulatedWalkInfo'; import { StatContainer } from '~components/Common/StatContainer'; -export const WalkInfo = ({ dogId }: { dogId: number }) => { - const { walkCount, countWalksWithMember, totalDistance } = useAccumulatedWalkInfo({ dogId }); +export const WalkInfo = ({ memberId }: { memberId: number }) => { + const { walkCount, countWalksWithMember, totalDistance } = useAccumulatedWalkInfo({ memberId }); return ( diff --git a/src/components/Social/Friend/index.tsx b/src/components/Social/Friend/index.tsx index 353b012..4fd1e7d 100644 --- a/src/components/Social/Friend/index.tsx +++ b/src/components/Social/Friend/index.tsx @@ -1,23 +1,53 @@ import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { createContext, useMemo, useState } from 'react'; import { useDogInfoByMemberId } from '~apis/dog/useDogInfoByMemberId'; import { FetchFriendsResponseType } from '~apis/friend/fetchFriends'; import { useFriends } from '~apis/friend/useFriends'; import { UserInfo } from '~components/Common/UserInfo'; -import { TabBarParamList } from '~navigation/BottomTabNavigator'; +import { FriendOptions } from '~components/Social/FriendOptions'; +import { SocialNavigations } from '~constants/navigations'; +import { SocialParamList } from '~navigation/SocialNavigator'; + +interface FriendOptionProps { + setIsFriendOptionsVisible: React.Dispatch>; + setFriendId: React.Dispatch>; +} + +export const FriendOptionContext = createContext(undefined); export const FriendTab = () => { const friends = useFriends(); - console.log({ friends }); + const [isFriendOptionsVisible, setIsFriendOptionsVisible] = useState(false); + const [friendId, setFriendId] = useState(1); + + const friendItems = useMemo(() => { + return ( + + + {friends?.map((friend, idx) => ( + + ))} + + + ); + }, [friends]); + return ( - - ( - - {friends?.map((friend, idx) => ( - - ))} - - ), - + <> + + {friendItems} + + setIsFriendOptionsVisible(false)} + friendId={friendId} + /> + ); }; @@ -31,22 +61,23 @@ const Item = ({ isLast: boolean; }) => { const dogInfos = useDogInfoByMemberId({ memberId }); - const navigation = useNavigation>(); - console.log({ dogInfos }); //todo 3번 멤버 강아지 없어서 에러 발생. 데이터 추가 요청하기 + const navigation = useNavigation>(); if (!dogInfos || !dogInfos.length) { return null; } return ( - navigation.navigate('Talk', { userId: friend.memberId })} - userId={friend.memberId} - isLast={isLast} - /> + <> + navigation.navigate(SocialNavigations.CHATROOM, { userId: friend.memberId })} + userId={friend.memberId} + isLast={isLast} + optionButton + /> + ); }; diff --git a/src/components/Social/FriendOptions/index.tsx b/src/components/Social/FriendOptions/index.tsx new file mode 100644 index 0000000..af773c5 --- /dev/null +++ b/src/components/Social/FriendOptions/index.tsx @@ -0,0 +1,64 @@ +import { useUserById } from '~apis/member/useUserById'; +import { CompoundOption } from '~components/Common/CompoundOptions'; +import { Profile } from '~components/Common/Profile'; +import { TextBold } from '~components/Common/Text'; +import * as S from './styles'; +import { useDeleteFriend } from '~apis/friend/useDeleteFriend'; +import { Alert } from 'react-native'; + +interface FriendOptionsProps { + isVisible: boolean; + hideOption: () => void; + friendId: number; +} + +export const FriendOptions = ({ isVisible, hideOption, friendId }: FriendOptionsProps) => { + const { data: friendInfo, isPending, isError } = useUserById({ memberId: friendId }); + const deleteFrinedMutation = useDeleteFriend(); + + if (isPending || isError) { + return <>; + } + + const handleDeleteFriend = () => { + Alert.alert(`'${friendInfo.memberName}'님을 친구 목록에서 삭제하시겠습니까?`, '', [ + { + text: '취소', + style: 'cancel', + }, + { + text: '삭제하기', + onPress: () => { + deleteFrinedMutation.mutate(friendId, { + onSuccess: hideOption, + onError: error => console.error(error), + onSettled: () => console.log(friendId), + }); + }, + }, + ]); + }; + + + return ( + + + + + + {friendInfo.memberName} + + + null}>상세 프로필 보기(미구현) + + + 친구 삭제 + + + + 취소 + + + + ); +}; diff --git a/src/components/Social/FriendOptions/styles.ts b/src/components/Social/FriendOptions/styles.ts new file mode 100644 index 0000000..957436e --- /dev/null +++ b/src/components/Social/FriendOptions/styles.ts @@ -0,0 +1,10 @@ +import styled from '@emotion/native'; + +export const FriendProfile = styled.View` + width: 100%; + height: 150px; + justify-content: center; + align-items: center; + text-align: center; + gap: 10px; +`; diff --git a/src/components/Social/TalkTab/index.tsx b/src/components/Social/TalkTab/index.tsx index bfec1bd..ebef553 100644 --- a/src/components/Social/TalkTab/index.tsx +++ b/src/components/Social/TalkTab/index.tsx @@ -4,11 +4,12 @@ import * as S from './styles'; import { UnreadChatCount } from '~components/Common/UnreadChatCount'; import { Pressable } from 'react-native'; import { NavigationProp, useNavigation } from '@react-navigation/native'; -import { TabBarParamList } from '~navigation/BottomTabNavigator'; import { useChatRooms } from '~apis/chat/useChatRooms'; -import { FetchChatRoomsResponseType } from '~apis/chat/fetchChatRooms'; -import { getKoreanRole } from '~utils/getKoreanRoleWithName'; -import { useDogInfoByMemberId } from '~apis/dog/useDogInfoByMemberId'; +import { SocialNavigations } from '~constants/navigations'; +import { SocialParamList } from '~navigation/SocialNavigator'; +import { ResponseFetchChatRoom } from '~apis/chat/fetchChatRooms'; +import { FAMILY_ROLE } from '~constants/family-role'; +import { useEffect } from 'react'; export const TalkTab = () => { const chatRooms = useChatRooms(); @@ -22,11 +23,15 @@ export const TalkTab = () => { ); }; -const TalkItem = ({ lastMessage, members, name, unreadMessageCount }: FetchChatRoomsResponseType[number]) => { - const navigation = useNavigation>(); - const opponentDogInfos = useDogInfoByMemberId({ memberId: members[0].memberId }); +const TalkItem = ({ lastMessage, members, name, unreadMessageCount }: ResponseFetchChatRoom) => { + const navigation = useNavigation>(); + + useEffect(() => { + console.log('members[0]', members[0]); + console.log('members[1]', members[1]); + }, [members]); return ( - navigation.navigate('Talk', { userId: members[0].memberId })}> + navigation.navigate(SocialNavigations.CHATROOM, { userId: members[0].memberId })}> @@ -34,10 +39,7 @@ const TalkItem = ({ lastMessage, members, name, unreadMessageCount }: FetchChatR {name} - {getKoreanRole({ - dogGender: opponentDogInfos ? opponentDogInfos[0]?.dogGender : 'MALE', - familyRole: members[0].familyRole, - })} + {FAMILY_ROLE[members[0].familyRole]} diff --git a/src/components/Talk/ChatRoomOptions/index.tsx b/src/components/Talk/ChatRoomOptions/index.tsx new file mode 100644 index 0000000..5eefc23 --- /dev/null +++ b/src/components/Talk/ChatRoomOptions/index.tsx @@ -0,0 +1,84 @@ +import { Alert } from 'react-native'; +import { useBlock } from '~apis/block/useBlock'; +import { CompoundOption } from '~components/Common/CompoundOptions'; +import { useToast } from '~hooks/useToast'; +import { queryClient } from '~providers/QueryClientProvider'; + +interface ChatRoomOptionsProps { + isVisible: boolean; + hideOption: () => void; + chatPartnerId: number; +} + +export const ChatRoomOptions = ({ isVisible, hideOption, chatPartnerId }: ChatRoomOptionsProps) => { + const { blockedUsers, blockUserMutation, unblockUserMutation } = useBlock(); + const { successToast } = useToast(); + + const isBlocked = blockedUsers.some(blockedUser => blockedUser.blockId === chatPartnerId); + + const handleBlock = () => { + Alert.alert( + '상대방을 차단하시겠습니까?', + '차단하면 차단한 상대방의 메시지를 더 이상 받지 않게 됩니다. 친구로 등록되어 있다면 친구 목록에서도 삭제됩니다.', + [ + { + text: '취소', + style: 'cancel', + }, + { + text: '차단하기', + onPress: () => + blockUserMutation.mutate(chatPartnerId, { + onSuccess: () => { + successToast('차단되었습니다'); + queryClient.invalidateQueries({ queryKey: ['allBlockedUsers'] }); + queryClient.invalidateQueries({ queryKey: ['friends'] }); + }, + onSettled: () => hideOption(), + }), + }, + ], + ); + }; + + const handleUnblock = () => { + Alert.alert('차단 해제하시겠습니까?', '', [ + { + text: '취소', + style: 'cancel', + }, + { + text: '차단 해제', + onPress: () => + unblockUserMutation.mutate(chatPartnerId, { + onSuccess: () => { + successToast('차단이 해제되었습니다'); + queryClient.invalidateQueries({ queryKey: ['allBlockedUsers'] }); + }, + onSettled: () => hideOption(), + }), + }, + ]); + }; + + return ( + + + + null}>채팅방 나가기(미구현) + + {isBlocked ? ( + 차단 해제 + ) : ( + + 차단하기 + + )} + + + 취소 + + + + ); +}; diff --git a/src/components/Talk/Message/styles.ts b/src/components/Talk/Message/styles.ts index 2a8f669..9ffd05d 100644 --- a/src/components/Talk/Message/styles.ts +++ b/src/components/Talk/Message/styles.ts @@ -8,6 +8,7 @@ const Message = styled(View)` white-space: pre-line; margin: 8px 0; z-index: 1; + max-width: 230px; `; export const IncomingMessage = styled(Message)` width: fit-content; diff --git a/src/components/Talk/TalkArea/index.tsx b/src/components/Talk/TalkArea/index.tsx index e3aced7..2de8cc5 100644 --- a/src/components/Talk/TalkArea/index.tsx +++ b/src/components/Talk/TalkArea/index.tsx @@ -1,71 +1,65 @@ -import { IncomingMessage, OutgoingMessage } from '~components/Talk/Message/styles'; +import { FlatList } from 'react-native'; import * as S from './styles'; import { TextBold } from '~components/Common/Text'; -import DogHowling from '~assets/dogs/dog-howling.svg'; import { Dimensions } from 'react-native'; +import { IncomingMessage, OutgoingMessage } from '~components/Talk/Message/styles'; +import DogHowling from '~assets/dogs/dog-howling.svg'; +import { useState } from 'react'; + +interface TalkAreaProps { + messages: any[]; +} -interface TalkAreaProps {} +export const TalkArea = ({ messages }: TalkAreaProps) => { + const deviceWidth = Dimensions.get('window').width; + const [talkAreaHeight, setTalkAreaHeight] = useState(0); + const [flatListHeight, setFlatListHeight] = useState(0); + + const renderMessage = ({ item }: { item: (typeof messages)[0] }) => { + if (item.type === 'incoming') { + return ( + + {item.text} + + ); + } + return ( + + {item.text} + + ); + }; -export const TalkArea = ({}: TalkAreaProps) => { - const width = Dimensions.get('window').width; return ( - + { + const { height } = event.nativeEvent.layout; + setTalkAreaHeight(height); + }} + > + item.id.toString()} + removeClippedSubviews={false} + inverted + onContentSizeChange={(_, height) => { + setFlatListHeight(height); }} /> - - 안녕하세요 성훈님 - - - 안녕하세요!! - {' '} - - 안녕하세요 성훈님 - - - 안녕하세요!! - {' '} - - 안녕하세요 성훈님 - - - 안녕하세요!! - {' '} - - 안녕하세요 성훈님 - - - 안녕하세요!! - - - 안녕하세요 성훈님 - - - 안녕하세요!! - {' '} - - 안녕하세요 성훈님 - - - 안녕하세요!! - {' '} - - 안녕하세요 성훈님 - - - 안녕하세요!! - {' '} - - 안녕하세요 성훈님 - - - 안녕하세요!! - ); }; diff --git a/src/components/Talk/TalkArea/styles.ts b/src/components/Talk/TalkArea/styles.ts index 7713f76..32a6495 100644 --- a/src/components/Talk/TalkArea/styles.ts +++ b/src/components/Talk/TalkArea/styles.ts @@ -1,8 +1,6 @@ import styled from '@emotion/native'; -export const TalkArea = styled.ScrollView` +export const TalkArea = styled.View` flex: 1; background-color: ${({ theme }) => theme.colors.lighten_3}; - padding: 20px; - gap: 0; `; diff --git a/src/constants/navigations.ts b/src/constants/navigations.ts index 4e25e79..188097c 100644 --- a/src/constants/navigations.ts +++ b/src/constants/navigations.ts @@ -1,3 +1,15 @@ +export const RootNavigations = { + BOTTOM_TAB: 'BottomTab', + REGISTER_DOG: 'RegisterDog', +} as const; + +export const AuthNavigations = { + LOGIN: 'Login', + KAKAO_LOGIN: 'KakaoLogin', + GOOGLE_LOGIN: 'GoogleLogin', + OWNER_PROFILE: 'OwnerProfile', +} as const; + export const RegisterDogNavigations = { HOME: 'Home', BASIC_PROFILE: 'BasicProfile', @@ -6,7 +18,27 @@ export const RegisterDogNavigations = { DOG_CONFIRMATION: 'DogConfirmation', } as const; +export const TabNavigations = { + HOME: 'Home', + LOG: 'Log', + SOCIAL: 'Social', + FAMILYDANG: 'FamilyDang', + MYPAGE: 'MyPage', + PROFILE: 'Profile', +} as const; + +export const HomeNavigations = { + MAIN: 'Main', + WALK: 'Walk', + NOTIFICATION: 'Notification', +} as const; + export const WalkLogNavigations = { - LogHome: 'LogHome', - Stats: 'Stats', + LOG_HOME: 'LogHome', + STATS: 'Stats', +} as const; + +export const SocialNavigations = { + SOCIAL_HOME: 'SocialHome', + CHATROOM: 'ChatRoom', } as const; diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts new file mode 100644 index 0000000..2e60cbb --- /dev/null +++ b/src/hooks/useChat.ts @@ -0,0 +1,59 @@ +import { Client } from '@stomp/stompjs'; +import { useEffect, useRef, useState } from 'react'; +import SockJS from 'sockjs-client'; +import { getAccessToken } from '~utils/controlAccessToken'; + +const SERVER_URL = 'https://ddang.site/ws'; + +export const useChat = (email: string) => { + const stompClientRef = useRef(null); + const [messages, setMessages] = useState([]); + + useEffect(() => { + const initializeWebSocket = async () => { + const accessToken = await getAccessToken(); + const stompClient = new Client({ + webSocketFactory: () => new SockJS(SERVER_URL), + reconnectDelay: 5000, + debug: msg => console.log(msg), + connectHeaders: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + stompClient.onConnect = () => { + console.log('STOMP 연결 성공'); + console.log('WebSocket 연결 상태:', stompClientRef.current?.connected); + + stompClient.subscribe(`/sub/${email}`, message => { + console.log(message.body); + const receivedMessage = JSON.parse(message.body); + console.log('받은 메시지', receivedMessage); + setMessages(prevMessages => [receivedMessage, ...prevMessages]); + }); + }; + + stompClient.activate(); + stompClientRef.current = stompClient; + + return () => { + stompClient.deactivate(); + }; + }; + + initializeWebSocket(); + }, [email]); + + const sendMessage = (receiverEmail: string, text: string) => { + if (stompClientRef.current && stompClientRef.current.connected) { + const payload = JSON.stringify({ receiverEmail, message: text }); + console.log('payload', payload); + stompClientRef.current.publish({ + destination: '/pub/api/v1/chat/message', + body: payload, + }); + } + }; + + return { messages, sendMessage }; +}; diff --git a/src/hooks/useImagePicker.ts b/src/hooks/useImagePicker.ts index eae39bc..f312af3 100644 --- a/src/hooks/useImagePicker.ts +++ b/src/hooks/useImagePicker.ts @@ -1,4 +1,3 @@ -import { Platform } from 'react-native'; import ImageCropPicker from 'react-native-image-crop-picker'; import { ImageFileType } from '~types/image-file'; @@ -9,7 +8,7 @@ export const useImagePicker = () => { } const file: ImageFileType = { - uri: Platform.OS === 'android' ? image.path.replace('file://', '') : `file://${image.path}`, + uri: `file://${image.path}`, type: image.mime, name: image.path.split('/').pop() || 'unknown.jpg', }; diff --git a/src/navigation/AuthNavigator.tsx b/src/navigation/AuthNavigator.tsx index 4823f64..64b3251 100644 --- a/src/navigation/AuthNavigator.tsx +++ b/src/navigation/AuthNavigator.tsx @@ -5,6 +5,8 @@ import { Icon } from '~components/Common/Icons'; import { Header } from '~components/Common/Header'; import { Login } from '~screens/Auth/Login'; import { GoogleLogin } from '~screens/Auth/GoogleLogin'; +import { AuthNavigations } from '~constants/navigations'; +import { useTheme } from '@emotion/react'; export type AuthParamList = { Login: undefined; @@ -15,24 +17,25 @@ export type AuthParamList = { export const AuthNavigator = () => { const Stack = createNativeStackNavigator(); + const theme = useTheme(); return ( ( @@ -41,7 +44,7 @@ export const AuthNavigator = () => { }} /> ( @@ -50,7 +53,7 @@ export const AuthNavigator = () => { }} />
} onLeftPress={() => navigation.goBack()} />, diff --git a/src/navigation/BottomTabNavigator.tsx b/src/navigation/BottomTabNavigator.tsx index 9167ef2..fb9c661 100644 --- a/src/navigation/BottomTabNavigator.tsx +++ b/src/navigation/BottomTabNavigator.tsx @@ -11,9 +11,10 @@ import { HomeNavigator } from '~navigation/HomeNavigator'; import { MyPageNavigator } from '~navigation/MyPageNavigator'; import { ProfileScreen } from '~screens/Profile'; import { WalkLogNavigator } from '~navigation/WalkLogNavigator'; -import { SocialScreen } from '~screens/Social'; -import { FamilyDDangNavigator, FamilyDdangParamList } from '~navigation/FamilyDDangNavigator'; -import { TalkScreen } from '~screens/Talk'; +import { FamilyDDangNavigator } from '~navigation/FamilyDDangNavigator'; +import { getFocusedRouteNameFromRoute } from '@react-navigation/native'; +import { HomeNavigations, SocialNavigations, TabNavigations, WalkLogNavigations } from '~constants/navigations'; +import { SocialNavigator } from '~navigation/SocialNavigator'; export type TabBarParamList = { Home: undefined; @@ -22,11 +23,15 @@ export type TabBarParamList = { FamilyDang: undefined; MyPage: undefined; Profile: { userId: number }; - FamilyDDang: { screen?: keyof FamilyDdangParamList }; - Talk: { userId: number }; }; const Tab = createBottomTabNavigator(); +const hiddenTabRoutes: ReadonlyArray = [ + HomeNavigations.NOTIFICATION, + WalkLogNavigations.STATS, + SocialNavigations.CHATROOM, + 'CreateInviteCode', +]; const TabIcon = ({ focused, name, size, color }: { focused: boolean; name: string } & IconButtonProps) => ( { const theme = useTheme(); return ( ({ tabBarActiveTintColor: '#783D16', tabBarLabelPosition: 'below-icon', headerShown: false, - }} + tabBarStyle: (tabRoute => { + const routeName = getFocusedRouteNameFromRoute(tabRoute); + if (routeName && hiddenTabRoutes.includes(routeName)) { + return { display: 'none' }; + } + })(route), + })} > , }} /> ( @@ -66,8 +77,8 @@ export const BottomTabNavigator = () => { }} /> ( @@ -75,7 +86,7 @@ export const BottomTabNavigator = () => { }} /> ( @@ -86,7 +97,7 @@ export const BottomTabNavigator = () => { /> ( @@ -95,7 +106,7 @@ export const BottomTabNavigator = () => { }} /> ({ tabBarButton: () => null, @@ -121,16 +132,6 @@ export const BottomTabNavigator = () => { animation: 'shift', })} /> - null, - tabBarItemStyle: { - display: 'none', - }, - }} - /> ); }; diff --git a/src/navigation/HomeNavigator.tsx b/src/navigation/HomeNavigator.tsx index 6e57942..cddb4b4 100644 --- a/src/navigation/HomeNavigator.tsx +++ b/src/navigation/HomeNavigator.tsx @@ -2,6 +2,7 @@ import { useTheme } from '@emotion/react'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { Header } from '~components/Common/Header'; import { Icon } from '~components/Common/Icons'; +import { HomeNavigations } from '~constants/navigations'; import { HomeScreen } from '~screens/Home'; import { NotificationScreen } from '~screens/Home/Notification'; import { WalkScreen } from '~screens/Home/WalkScreen'; @@ -30,10 +31,14 @@ export const HomeNavigator = () => { }, }} > - - + + ( diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 1ec7dad..b730317 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -6,6 +6,7 @@ import { useAuth } from '~apis/member/useAuth'; import { useEffect } from 'react'; import SplashScreen from 'react-native-splash-screen'; import { NavigatorScreenParams } from '@react-navigation/native'; +import { RootNavigations } from '~constants/navigations'; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -37,8 +38,8 @@ export const RootNavigator = () => { return ( - - + + ); }; diff --git a/src/navigation/SocialNavigator.tsx b/src/navigation/SocialNavigator.tsx new file mode 100644 index 0000000..6cde0c4 --- /dev/null +++ b/src/navigation/SocialNavigator.tsx @@ -0,0 +1,30 @@ +import { useTheme } from '@emotion/react'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { SocialNavigations } from '~constants/navigations'; +import { SocialHomeScreen } from '~screens/Social'; +import { ChatRoomScreen } from '~screens/Social/ChatRoom'; + +export type SocialParamList = { + SocialHome: undefined; + ChatRoom: { userId: number }; +}; + +export const SocialNavigator = () => { + const Stack = createNativeStackNavigator(); + const theme = useTheme(); + + return ( + + + + + ); +}; diff --git a/src/navigation/WalkLogNavigator.tsx b/src/navigation/WalkLogNavigator.tsx index 42ceb63..e73b99b 100644 --- a/src/navigation/WalkLogNavigator.tsx +++ b/src/navigation/WalkLogNavigator.tsx @@ -26,7 +26,7 @@ export const WalkLogNavigator = () => { options={() => ({ headerShown: false, })} - name={WalkLogNavigations.LogHome} + name={WalkLogNavigations.LOG_HOME} component={LogHome} /> { ), headerTitle: '산책 분석', })} - name={WalkLogNavigations.Stats} + name={WalkLogNavigations.STATS} component={Stats} /> diff --git a/src/screens/Auth/KakaoLogin/index.tsx b/src/screens/Auth/KakaoLogin/index.tsx index 6b7aaca..c6189c3 100644 --- a/src/screens/Auth/KakaoLogin/index.tsx +++ b/src/screens/Auth/KakaoLogin/index.tsx @@ -7,6 +7,7 @@ import { storeAccessToken } from '~utils/controlAccessToken'; import { queryClient } from '~providers/QueryClientProvider'; import { useState } from 'react'; import { ActivityIndicator, Dimensions } from 'react-native'; +import { AuthNavigations } from '~constants/navigations'; export const KakaoLogin = () => { const navigation = useNavigation>(); @@ -29,7 +30,7 @@ export const KakaoLogin = () => { const provider = params.get('provider') || ''; console.log('Register Params:', { email, provider }); - navigation.replace('OwnerProfile', { email, provider }); + navigation.replace(AuthNavigations.OWNER_PROFILE, { email, provider }); } else if (url.includes('accessToken')) { const accessToken = params.get('accessToken') || ''; diff --git a/src/screens/FamilyDang/FamilyInfo/familylist.tsx b/src/screens/FamilyDang/FamilyInfo/familylist.tsx index a2e2cea..94b390c 100644 --- a/src/screens/FamilyDang/FamilyInfo/familylist.tsx +++ b/src/screens/FamilyDang/FamilyInfo/familylist.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import * as S from '../styles'; import { Separator } from '~components/Common/Seperator'; import { useFamilyInfo } from '~apis/family/useFamilyInfo'; diff --git a/src/screens/FamilyDang/FamilySetting/FamilyOut/index.tsx b/src/screens/FamilyDang/FamilySetting/FamilyOut/index.tsx index 49e8a7f..c135e44 100644 --- a/src/screens/FamilyDang/FamilySetting/FamilyOut/index.tsx +++ b/src/screens/FamilyDang/FamilySetting/FamilyOut/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import * as S from '../styles'; import { ClickFamily } from '~screens/FamilyDang/FamilyInfo/clickfamily'; import { FamilyComment } from '~screens/FamilyDang/FamilyInfo/familycomment'; @@ -73,4 +73,4 @@ export const FamilyOut = () => { ); -}; +}; \ No newline at end of file diff --git a/src/screens/FamilyDang/FamilySetting/index.tsx b/src/screens/FamilyDang/FamilySetting/index.tsx index bd29c69..0af3ffd 100644 --- a/src/screens/FamilyDang/FamilySetting/index.tsx +++ b/src/screens/FamilyDang/FamilySetting/index.tsx @@ -147,4 +147,4 @@ export const FamilySetting = () => { ); -}; +}; \ No newline at end of file diff --git a/src/screens/Log/index.tsx b/src/screens/Log/index.tsx index 5adfe1c..af005e8 100644 --- a/src/screens/Log/index.tsx +++ b/src/screens/Log/index.tsx @@ -58,7 +58,7 @@ export const LogHome = () => { /> } right={ - navigation.navigate(WalkLogNavigations.Stats)}> + navigation.navigate(WalkLogNavigations.STATS)}> } diff --git a/src/screens/Profile/index.tsx b/src/screens/Profile/index.tsx index 77ad8c3..271bc48 100644 --- a/src/screens/Profile/index.tsx +++ b/src/screens/Profile/index.tsx @@ -1,8 +1,6 @@ import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; -import { useQuery } from '@tanstack/react-query'; import { Suspense, useEffect } from 'react'; import ErrorBoundary from 'react-native-error-boundary'; -import { fetchUserById } from '~apis/member/fetchUserById'; import { DogProfile } from '~components/Profile/DogProfile'; import { DogProfileFallback } from '~components/Profile/DogProfile/fallback'; import { DogProfileLoader } from '~components/Profile/DogProfile/loader'; @@ -14,22 +12,23 @@ import { WalkInfoFallback } from '~components/Profile/WalkInfo/fallback'; import { WalkInfoLoader } from '~components/Profile/WalkInfo/loader'; import { TabBarParamList } from '~navigation/BottomTabNavigator'; import * as S from './styles'; +import { useUserById } from '~apis/member/useUserById'; interface ProfileScreenProps extends BottomTabScreenProps {} export const ProfileScreen = ({ navigation, route }: ProfileScreenProps) => { - const memberId = route.params!.userId; //! 항상 params로 userId를 넘겨줌 - const { data: userInfoById } = useQuery({ - queryKey: ['userInfoById', memberId], - queryFn: () => fetchUserById({ memberId }), - select: ({ data }) => data, - }); + const memberId = route.params!.userId; + const { data: user, isPending, isError } = useUserById({ memberId }); useEffect(() => { navigation.setOptions({ - headerTitle: userInfoById?.memberName, + headerTitle: user?.memberName, }); - }, [navigation, userInfoById?.memberName]); + }, [navigation, user?.memberName]); + + if (isPending || isError) { + return <>; + } return ( @@ -40,12 +39,12 @@ export const ProfileScreen = ({ navigation, route }: ProfileScreenProps) => { }> - + }> - + diff --git a/src/screens/Social/ChatRoom/index.tsx b/src/screens/Social/ChatRoom/index.tsx new file mode 100644 index 0000000..781c5fd --- /dev/null +++ b/src/screens/Social/ChatRoom/index.tsx @@ -0,0 +1,87 @@ +import { Separator } from '~components/Common/Seperator'; +import * as S from './styles'; +import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; +import { TalkArea } from '~components/Talk/TalkArea'; +import { Icon } from '~components/Common/Icons'; +import { SocialParamList } from '~navigation/SocialNavigator'; +import { KeyboardAvoidingView, Platform } from 'react-native'; +import { TextBold } from '~components/Common/Text'; +import { useUserById } from '~apis/member/useUserById'; +import { Profile } from '~components/Common/Profile'; +import { FAMILY_ROLE } from '~constants/family-role'; +import { useState } from 'react'; +import { ChatRoomOptions } from '~components/Talk/ChatRoomOptions'; +import { useUser } from '~apis/member/useUser'; +import { useChat } from '~hooks/useChat'; + +interface TalkScreenProps extends BottomTabScreenProps {} + +export const ChatRoomScreen = ({ navigation, route }: TalkScreenProps) => { + const memberId = route.params!.userId; + const { data: chatPartner, isPending, isError } = useUserById({ memberId }); + const myInfo = useUser(); + const [isOptionVisible, setIsOptionVisible] = useState(false); + const [inputText, setInputText] = useState(''); + const { messages, sendMessage } = useChat(myInfo.email); + + if (isPending || isError) { + return <>; + } + + const handleSendMessage = () => { + if (inputText.trim()) { + sendMessage(chatPartner.email, inputText); + setInputText(''); + } + }; + + return ( + + + + + navigation.goBack()} /> + + + {chatPartner.memberName} + + {chatPartner.memberGender === 'MALE' ? '남자' : '여자'} + + {FAMILY_ROLE[chatPartner.familyRole]} + + + + setIsOptionVisible(true)} + /> + + + + + + + + 전송 + + + + setIsOptionVisible(false)} + chatPartnerId={chatPartner.memberId} + /> + + + ); +}; diff --git a/src/screens/Talk/styles.ts b/src/screens/Social/ChatRoom/styles.ts similarity index 65% rename from src/screens/Talk/styles.ts rename to src/screens/Social/ChatRoom/styles.ts index e6e70a7..4740677 100644 --- a/src/screens/Talk/styles.ts +++ b/src/screens/Social/ChatRoom/styles.ts @@ -1,9 +1,9 @@ import styled from '@emotion/native'; import { Theme } from '@emotion/react'; -import { TextInput } from 'react-native'; +import { Platform, TextInput } from 'react-native'; import { TextBold, TextMedium } from '~components/Common/Text'; -export const Talk = styled.View` +export const Talk = styled.SafeAreaView` flex: 1; `; export const Header = styled.View` @@ -27,11 +27,13 @@ export const Gender = styled(TextMedium)``; export const FamilyRole = styled(TextMedium)``; export const TalkInputWrapper = styled.View` width: 100%; - position: fixed; - bottom: 0; height: 64px; padding: 12px 20px; + padding-top: ${Platform.OS === 'ios' ? 4 + 'px' : 12 + 'px'}; background-color: ${({ theme }) => theme.colors.gc_4}; + flex-direction: row; + align-items: center; + gap: 20px; `; interface TextProps { @@ -39,11 +41,27 @@ interface TextProps { color?: keyof Theme['colors']; } export const TalkInput = styled(TextInput)` + flex: 1; font-family: 'SUIT-Medium'; - width: 100%; font-size: ${({ fontSize }) => fontSize + 'px'}; color: ${({ theme, color = 'font_1' }) => theme.colors[color]}; line-height: ${({ fontSize }) => fontSize * 1.5 + 'px'}; letter-spacing: ${({ fontSize }) => fontSize * -0.025 + 'px'}; - // medium 15px +`; + +export const MessageSendButtonWrapper = styled.View` + width: 57px; + height: 64px; + justify-content: center; + align-items: center; + transform: translateY(${Platform.OS === 'ios' ? 3 + 'px' : 0 + 'px'}); +`; + +export const MessageSendButton = styled.Pressable` + width: 57px; + height: 40px; + background-color: ${props => props.theme.colors.lighten_2}; + border-radius: 32px; + justify-content: center; + align-items: center; `; diff --git a/src/screens/Social/index.tsx b/src/screens/Social/index.tsx index 7313588..4322d87 100644 --- a/src/screens/Social/index.tsx +++ b/src/screens/Social/index.tsx @@ -6,16 +6,16 @@ import { BlockedUsersLoader } from '~components/MyPage/Block/BlockedUsers/loader import { FriendTab } from '~components/Social/Friend'; import { Tab } from '~components/Social/Tab'; import { TalkTab } from '~components/Social/TalkTab'; -import { TabBarParamList } from '~navigation/BottomTabNavigator'; import * as S from './styles'; +import { SocialParamList } from '~navigation/SocialNavigator'; +import { SocialNavigations } from '~constants/navigations'; -type Props = BottomTabScreenProps; +type Props = BottomTabScreenProps; -export const SocialScreen = ({}: Props) => { +export const SocialHomeScreen = ({}: Props) => { const [selectedTab, setSelectedTab] = useState<'댕친' | '댕톡'>('댕친'); - return ( - + 소셜 @@ -29,6 +29,6 @@ export const SocialScreen = ({}: Props) => { ) : ( )} - + ); }; diff --git a/src/screens/Social/styles.ts b/src/screens/Social/styles.ts index ef9add2..f263294 100644 --- a/src/screens/Social/styles.ts +++ b/src/screens/Social/styles.ts @@ -2,7 +2,7 @@ import styled from '@emotion/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { TextBold } from '~components/Common/Text'; -export const SocialScreen = styled(SafeAreaView)` +export const SocialHomeScreen = styled(SafeAreaView)` background-color: ${({ theme }) => theme.colors.gc_4}; flex: 1; `; diff --git a/src/screens/Talk/index.tsx b/src/screens/Talk/index.tsx deleted file mode 100644 index 2845288..0000000 --- a/src/screens/Talk/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Separator } from '~components/Common/Seperator'; -import { Profile } from '~components/Common/Profile'; -import { getKoreanRole } from '~utils/getKoreanRoleWithName'; -import * as S from './styles'; -import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; -import { TabBarParamList } from '~navigation/BottomTabNavigator'; -import { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { fetchUserById } from '~apis/member/fetchUserById'; -import { TalkArea } from '~components/Talk/TalkArea'; -import { Icon } from '~components/Common/Icons'; - -interface TalkScreenProps extends BottomTabScreenProps {} - -export const TalkScreen = ({ navigation, route }: TalkScreenProps) => { - const memberId = route.params!.userId; //! 항상 params로 userId를 넘겨줌 - const { data: userInfoById } = useQuery({ - queryKey: ['userInfoById', memberId], - queryFn: () => fetchUserById({ memberId }), - select: ({ data }) => data, - }); - const avatarNumber = 1; - const userId = 1; - const name = '감자탕수육'; - const gender = 'MALE'; - const dogGender = 'FEMALE'; - const familyRole = 'FATHER'; - - useEffect(() => { - navigation.setOptions({ - headerTitle: userInfoById?.memberName, - }); - }, [navigation, userInfoById?.memberName]); - return ( - - - - navigation.navigate('Social')} /> - - - {name} - - {gender === 'MALE' ? '남자' : '여자'} - - {getKoreanRole({ dogGender, familyRole })} - - - - - - - - - - {/* 전송 버튼 만들기 */} - - ); -};