diff --git a/apps/client/src/app/[user]/components/CommentInput/index.tsx b/apps/client/src/app/[user]/components/CommentInput/index.tsx index 230cf99f..42b44226 100644 --- a/apps/client/src/app/[user]/components/CommentInput/index.tsx +++ b/apps/client/src/app/[user]/components/CommentInput/index.tsx @@ -13,12 +13,14 @@ interface CommentInputProps { profileUrl?: string; nickname?: string; solutionId: number; + onCommentCountPlus: () => void; } const CommentInput = ({ profileUrl, nickname, solutionId, + onCommentCountPlus, }: CommentInputProps) => { const [comment, setComment] = useState(""); @@ -33,6 +35,7 @@ const CommentInput = ({ postComment(comment); setComment(""); + onCommentCountPlus(); }; return ( diff --git a/apps/client/src/app/[user]/components/FeedItem/index.css.ts b/apps/client/src/app/[user]/components/FeedItem/index.css.ts index 78f19d8f..d057bb5b 100644 --- a/apps/client/src/app/[user]/components/FeedItem/index.css.ts +++ b/apps/client/src/app/[user]/components/FeedItem/index.css.ts @@ -74,3 +74,39 @@ export const commentStyle = style({ ...theme.font.Caption1_R_12, color: theme.color.mg1, }); + +export const moreCommentContainer = style({ + alignSelf: "flex-start", + + paddingLeft: "2rem", + + color: theme.color.mg2, + + cursor: "pointer", + ":hover": { + color: theme.color.mg1, + }, +}); + +export const moreCommentWrapper = style({ + display: "flex", + justifyContent: "center", + alignItems: "center", + gap: "0.8rem", + + padding: "0.6rem 0", + + color: "inherit", + ...theme.font.Caption1_R_12, +}); + +export const moreCommentButtonStyle = style({ + padding: "0.2rem 0.8rem", + + backgroundColor: theme.color.mg5, + borderRadius: "4px", + + color: "inherit", + + cursor: "pointer", +}); diff --git a/apps/client/src/app/[user]/components/FeedItem/index.tsx b/apps/client/src/app/[user]/components/FeedItem/index.tsx index e59b0a19..de857ef1 100644 --- a/apps/client/src/app/[user]/components/FeedItem/index.tsx +++ b/apps/client/src/app/[user]/components/FeedItem/index.tsx @@ -14,6 +14,9 @@ import { feedItemContainer, infoTextWrapper, infoWrapper, + moreCommentButtonStyle, + moreCommentContainer, + moreCommentWrapper, nameStyle, studyNameStyle, } from "@/app/[user]/components/FeedItem/index.css"; @@ -23,13 +26,21 @@ import { useGroupInfoQueryObject } from "@/app/api/groups/query"; import { useSolutionQueryObject } from "@/app/api/solutions/query"; import { formatDistanceDate } from "@/common/util/date"; import { useSuspenseQueries } from "@tanstack/react-query"; +import Link from "next/link"; +import { useMemo, useRef } from "react"; interface FeedItemProps { solutionId: number; groupId: number; } +const DEFAULT_COMMENT_COUNT = 3; + const FeedItem = ({ solutionId, groupId }: FeedItemProps) => { + const myAddedCommentsRef = useRef(0); + const displayedCommentCount = + myAddedCommentsRef.current + DEFAULT_COMMENT_COUNT; + const [{ data: solution }, { data: comments }, { data: group }] = useSuspenseQueries({ queries: [ @@ -50,14 +61,14 @@ const FeedItem = ({ solutionId, groupId }: FeedItemProps) => { }); // 피드에 뜨게한 댓글 찾기 - 나를 제외한 최신 댓글 - const triggerComment = comments?.find( - (comment) => comment.writerNickname !== solution?.nickname, + const triggerComment = useMemo( + () => + comments?.find( + (comment) => comment.writerNickname !== solution?.nickname, + ), + [comments, solution?.nickname], ); - if (!triggerComment) { - return null; - } - const [ triggerCommentWritterName, triggerCommentWritterProfileImage, @@ -68,6 +79,10 @@ const FeedItem = ({ solutionId, groupId }: FeedItemProps) => { triggerComment?.createdAt, ]; + const handleCommentCountPlus = () => { + myAddedCommentsRef.current += 1; + }; + return (
  • @@ -103,7 +118,7 @@ const FeedItem = ({ solutionId, groupId }: FeedItemProps) => { /> + {comments?.length > displayedCommentCount && ( + +
    + {`댓글 +${comments?.length - displayedCommentCount}`} + 더보기 +
    + + )} + { mutationFn: (id: number) => patchNotificationRead(id), onMutate: async (id: number) => { await queryClient.cancelQueries({ - queryKey: notificationQueryKey.lists(notificationType), + queryKey: notificationQueryKey.typeList(notificationType), }); const prev = queryClient.getQueryData( - notificationQueryKey.lists(notificationType), + notificationQueryKey.typeList(notificationType), ); const newData = prev?.map((item) => item.id === id ? { ...item, isRead: true } : item, ); queryClient.setQueryData( - notificationQueryKey.lists(notificationType), + notificationQueryKey.typeList(notificationType), newData, ); return { prev }; }, - onSettled: async () => { - await queryClient.invalidateQueries({ - queryKey: notificationQueryKey.lists(notificationType), - }); + onSuccess: (_data, id) => { + queryClient.setQueriesData( + { queryKey: [...notificationQueryKey.all(), "list"] }, + (oldData) => { + if (!oldData) return []; + return oldData.map((item) => + item.id === id ? { ...item, isRead: true } : item, + ); + }, + ); }, onError: (_err, _new, context) => { queryClient.setQueryData( - notificationQueryKey.lists(notificationType), + notificationQueryKey.typeList(notificationType), context?.prev, ); }, @@ -113,37 +120,79 @@ export const useDeleteNotiMutation = (notificationType: NotificationType) => { return useMutation({ mutationFn: (id: number) => deleteNotification(id), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: notificationQueryKey.lists(notificationType), + onMutate: async (id: number) => { + await queryClient.cancelQueries({ + queryKey: notificationQueryKey.typeList(notificationType), }); + const prev = queryClient.getQueryData( + notificationQueryKey.typeList(notificationType), + ); + const newData = prev?.filter((item) => item.id !== id); + queryClient.setQueryData( + notificationQueryKey.typeList(notificationType), + newData, + ); + return { prev, id }; + }, + onSuccess: (_data, id) => { + // 모든 알림 리스트 쿼리에서 해당 알림 제거 + queryClient.setQueriesData( + { queryKey: [...notificationQueryKey.all(), "list"] }, + (oldData) => { + if (!oldData) return []; + return oldData.filter((item) => item.id !== id); + }, + ); + }, + onError: (_err, _new, context) => { + if (context?.prev) { + queryClient.setQueryData( + notificationQueryKey.typeList(notificationType), + context.prev, + ); + } }, }); }; -// export const useReadAllNotiMutation = () => { -// const queryClient = useQueryClient(); - -// return useMutation({ -// mutationFn: patchAllNotificationRead, -// onMutate: async () => { -// await queryClient.cancelQueries({ -// queryKey: notificationQueryKey.lists(), -// }); -// const prev = queryClient.getQueryData( -// notificationQueryKey.lists(), -// ); -// const newData = prev?.map((item) => ({ ...item, isRead: true })); -// queryClient.setQueryData(notificationQueryKey.lists(), newData); -// return { prev }; -// }, -// onSettled: async () => { -// await queryClient.invalidateQueries({ -// queryKey: notificationQueryKey.lists(), -// }); -// }, -// onError: (_err, _new, context) => { -// queryClient.setQueryData(notificationQueryKey.lists(), context?.prev); -// }, -// }); -// }; +export const useReadAllNotiMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: patchAllNotificationRead, + onMutate: async () => { + await queryClient.cancelQueries({ + queryKey: notificationQueryKey.list(), + }); + + // 모든 매칭되는 쿼리의 이전 데이터 저장 + const previousQueries = queryClient.getQueriesData({ + queryKey: notificationQueryKey.list(), + }); + + // 모든 쿼리를 optimistic update + queryClient.setQueriesData( + { queryKey: notificationQueryKey.list() }, + (oldData) => { + if (!oldData) return []; + return oldData.map((item) => ({ ...item, isRead: true })); + }, + ); + + return { previousQueries }; + }, + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: notificationQueryKey.list(), + }); + }, + onError: (_err, _new, context) => { + // 실패 시 모든 쿼리를 이전 데이터로 롤백 + if (context?.previousQueries) { + for (const [queryKey, data] of context.previousQueries) { + queryClient.setQueryData(queryKey, data); + } + } + }, + }); +}; diff --git a/apps/client/src/app/api/notifications/query.ts b/apps/client/src/app/api/notifications/query.ts index a6c87531..db6f9bc1 100644 --- a/apps/client/src/app/api/notifications/query.ts +++ b/apps/client/src/app/api/notifications/query.ts @@ -5,8 +5,9 @@ import { getNotificationList, getNotificationsSettings } from "./index"; export const notificationQueryKey = { all: () => ["notifications"] as const, settings: () => [...notificationQueryKey.all(), "settings"] as const, - lists: (notificationType: NotificationType) => - [...notificationQueryKey.all(), "list", notificationType] as const, + list: () => [...notificationQueryKey.all(), "list"] as const, + typeList: (notificationType?: NotificationType) => + [...notificationQueryKey.list(), notificationType] as const, }; export const useNotificationSettingListQueryObject = () => @@ -19,6 +20,6 @@ export const useNotificationsQueryObject = ( notificationType: NotificationType, ) => queryOptions({ - queryKey: notificationQueryKey.lists(notificationType), + queryKey: notificationQueryKey.typeList(notificationType), queryFn: () => getNotificationList({ notificationType }), }); diff --git a/apps/client/src/shared/component/Header/Notification/index.css.ts b/apps/client/src/shared/component/Header/Notification/index.css.ts index d24e0ff8..73dec818 100644 --- a/apps/client/src/shared/component/Header/Notification/index.css.ts +++ b/apps/client/src/shared/component/Header/Notification/index.css.ts @@ -22,6 +22,14 @@ export const notificationContainer = style({ }); export const headerStyle = style({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + + marginRight: "1.6rem", +}); + +export const titleWrapper = style({ display: "flex", alignItems: "center", gap: "0.8rem", @@ -32,6 +40,27 @@ export const titleStyle = style({ ...theme.font.Head2_B_18, }); +export const readAllButtonStyle = recipe({ + base: { + padding: "0.4rem 0.8rem", + + borderRadius: "17px", + backgroundColor: theme.color.mg5, + + ...theme.font.Body1_M_14, + }, + variants: { + isAllRead: { + true: { + color: theme.color.mg3, + }, + false: { + color: theme.color.lg1, + }, + }, + }, +}); + export const countChipStyle = style({ display: "flex", alignItems: "center", diff --git a/apps/client/src/shared/component/Header/Notification/index.tsx b/apps/client/src/shared/component/Header/Notification/index.tsx index d56d0457..2edad436 100644 --- a/apps/client/src/shared/component/Header/Notification/index.tsx +++ b/apps/client/src/shared/component/Header/Notification/index.tsx @@ -1,15 +1,18 @@ "use client"; +import { useReadAllNotiMutation } from "@/app/api/notifications/mutation"; import { useNotificationsQueryObject } from "@/app/api/notifications/query"; import { IcnBellHeader } from "@/asset/svg"; import NotificationList from "@/shared/component/Header/Notification/NotificationList"; import NotificationTab from "@/shared/component/Header/Notification/NotificationTab"; -import { notificationTabListStyle } from "@/shared/component/Header/Notification/index.css"; import { countChipStyle, countStyle, headerStyle, notificationContainer, + notificationTabListStyle, + readAllButtonStyle, titleStyle, + titleWrapper, } from "@/shared/component/Header/Notification/index.css"; import { iconStyle } from "@/shared/component/Header/index.css"; import { useQuery } from "@tanstack/react-query"; @@ -39,6 +42,8 @@ const Notification = () => { const { data } = useQuery(useNotificationsQueryObject(notificationType)); const notiCounts = data ? data.filter((item) => !item.isRead).length : 0; + const { mutate: readAllNotifications } = useReadAllNotiMutation(); + const shrinkList = () => { setIsExpanded(false); }; @@ -50,10 +55,19 @@ const Notification = () => { return (
    -

    - 알림 -

    -
    {`신규 ${notiCounts}`}
    +
    +

    + 알림 +

    +
    {`신규 ${notiCounts}`}
    +
    +