Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions apps/web/src/app/community/[boardCode]/CommunityPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,11 @@ import { COMMUNITY_INITIAL_CATEGORY } from "@/apis/community/postListQuery";
import ButtonTab from "@/components/ui/ButtonTab";
import { COMMUNITY_BOARDS, COMMUNITY_CATEGORIES } from "@/constants/community";
import useReportedPostsStore from "@/lib/zustand/useReportedPostsStore";
import type { ListPost } from "@/types/community";
import { CommunityPostListSkeleton } from "./CommunityPageSkeleton";
import CommunityRegionSelector from "./CommunityRegionSelector";
import PostCards from "./PostCards";
import PostWriteButton from "./PostWriteButton";

type ListPostWithAuthor = ListPost & {
postFindSiteUserResponse?: {
id: number;
};
};

interface CommunityPageContentProps {
boardCode: string;
}
Expand All @@ -28,34 +21,36 @@ const CommunityPageContent = ({ boardCode }: CommunityPageContentProps) => {
const [category, setCategory] = useState<string | null>(COMMUNITY_INITIAL_CATEGORY);
const reportedPostIds = useReportedPostsStore((state) => state.reportedPostIds);
const blockedUserIds = useReportedPostsStore((state) => state.blockedUserIds);
const blockedPostIds = useReportedPostsStore((state) => state.blockedPostIds);

const { data: posts = [], isPending } = useGetPostList({
boardCode,
category,
});

const visiblePosts = useMemo(() => {
if (reportedPostIds.length === 0 && blockedUserIds.length === 0) {
if (reportedPostIds.length === 0 && blockedUserIds.length === 0 && blockedPostIds.length === 0) {
return posts;
}

const reportedIdSet = new Set(reportedPostIds);
const blockedUserIdSet = new Set(blockedUserIds);
const blockedPostIdSet = new Set(blockedPostIds);

return posts.filter((post) => {
if (reportedIdSet.has(post.id)) {
if (reportedIdSet.has(post.id) || blockedPostIdSet.has(post.id)) {
return false;
}

const authorId = (post as ListPostWithAuthor).postFindSiteUserResponse?.id;
const authorId = post.postFindSiteUserResponse?.id;

if (typeof authorId === "number" && blockedUserIdSet.has(authorId)) {
Comment on lines +45 to 47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require author IDs before relying on block filters

When the /boards/{boardCode} list payload does not include postFindSiteUserResponse (the repository’s current board-list shape in apps/web/src/apis/community/api.ts only defines id/title/content/counts/category/thumbnail for this endpoint), authorId is undefined and every blocked author’s post passes this filter. In that scenario, after blocking someone from a post detail the app routes back to the list but still renders that author’s posts, so the new local block feature only works on detail/comment data that has author IDs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다. 목록 응답에 작성자 ID가 없을 때도 차단을 누른 현재 게시글은 다시 노출되지 않도록 blockedPostIds를 저장하고, 목록/상세 가드에서 함께 필터링하도록 보완했습니다.

return false;
}

return true;
});
}, [posts, reportedPostIds, blockedUserIds]);
}, [posts, reportedPostIds, blockedUserIds, blockedPostIds]);

const handleBoardChange = (newBoard: string) => {
router.push(`/community/${newBoard}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"use client";

import clsx from "clsx";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useDeleteComment } from "@/apis/community";
import useBlockCommunityUser from "@/app/community/_hooks/useBlockCommunityUser";
import Dropdown from "@/components/ui/Dropdown";
import Image from "@/components/ui/FallbackImage";
import { DEFAULT_PROFILE_IMAGE } from "@/constants/profile";
import useReportedPostsStore from "@/lib/zustand/useReportedPostsStore";
import { IconMoreVertFilled, IconSubComment } from "@/public/svgs";
import type { Comment as CommentType, CommunityUser } from "@/types/community";
import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl";
Expand All @@ -21,17 +23,28 @@ type CommentSectionProps = {
const CommentSection = ({ comments, postId, refresh }: CommentSectionProps) => {
const [curSelectedComment, setCurSelectedComment] = useState<number | null>(null);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const blockedUserIds = useReportedPostsStore((state) => state.blockedUserIds);

const deleteCommentMutation = useDeleteComment();

const visibleComments = useMemo(() => {
if (blockedUserIds.length === 0) {
return comments;
}

const blockedUserIdSet = new Set(blockedUserIds);

return comments.filter((comment) => !blockedUserIdSet.has(comment.postFindSiteUserResponse.id));
}, [comments, blockedUserIds]);

const deleteComment = (commentId: number) => {
if (!window.confirm("정말 삭제하시겠습니까?")) return;
deleteCommentMutation.mutate({ commentId, postId });
};

return (
<div className="min-h-[50vh] pb-[49px]">
{comments?.map((comment) => (
{visibleComments?.map((comment) => (
<Comment
key={comment.id}
comment={comment}
Expand Down Expand Up @@ -114,11 +127,15 @@ const Comment = ({
nickname: isDeleted ? "알 수 없음" : comment.postFindSiteUserResponse.nickname,
}}
/>
{comment.isOwner && (
{!isDeleted && (
<CommentDropdown
commentId={comment.id}
isOwner={comment.isOwner}
authorId={comment.postFindSiteUserResponse.id}
authorNickname={comment.postFindSiteUserResponse.nickname}
activeDropdown={activeDropdown}
toggleDropdown={toggleDropdown}
setActiveDropdown={setActiveDropdown}
deleteComment={deleteComment}
/>
)}
Expand Down Expand Up @@ -151,32 +168,52 @@ const CommentProfile = ({ user }: { user: CommunityUser }) => {

const CommentDropdown = ({
commentId,
isOwner,
authorId,
authorNickname,
activeDropdown,
toggleDropdown,
setActiveDropdown,
deleteComment,
}: {
commentId: number;
isOwner: boolean;
authorId: number;
authorNickname: string;
activeDropdown: number | null;
toggleDropdown: (commentId: number) => void;
setActiveDropdown: (commentId: number | null) => void;
deleteComment: (commentId: number) => void;
}) => {
const { handleBlockUser } = useBlockCommunityUser({
onBlocked: () => setActiveDropdown(null),
});

const options = isOwner
? [
{
label: "삭제하기",
action: () => {
deleteComment(commentId);
},
},
]
: [
{
label: "차단하기",
action: () => {
setActiveDropdown(null);
void handleBlockUser({ userId: authorId, nickname: authorNickname });
},
},
];

return (
<div className="relative">
<div className="relative" onClick={(event) => event.stopPropagation()}>
<button className="cursor-pointer" onClick={() => toggleDropdown(commentId)} aria-label="더보기">
<IconMoreVertFilled />
</button>
{activeDropdown === commentId && (
<Dropdown
options={[
{
label: "삭제하기",
action: () => {
deleteComment(commentId);
},
},
]}
/>
)}
{activeDropdown === commentId && <Dropdown options={options} />}
</div>
);
};
24 changes: 23 additions & 1 deletion apps/web/src/app/community/[boardCode]/[postId]/KebabMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";

import { Ban } from "lucide-react";
import { useRouter } from "next/navigation";
import { type RefObject, useEffect, useRef, useState } from "react";
import { useDeletePost } from "@/apis/community";
import useBlockCommunityUser from "@/app/community/_hooks/useBlockCommunityUser";
import ReportPanel from "@/components/ui/ReportPanel";
import { showIconToast } from "@/lib/toast/showIconToast";
import { IconSetting } from "@/public/svgs/mentor";
Expand Down Expand Up @@ -52,6 +54,9 @@ const KebabMenu = ({ postId, boardCode, isOwner = false, authorId }: KebabMenuPr
const dropdownRef = useRef<HTMLDivElement>(null);
const { mutate: deletePost } = useDeletePost();
const router = useRouter();
const { handleBlockUser, isBlocking } = useBlockCommunityUser({
onBlocked: () => router.replace(`/community/${boardCode}`),
});

const [isDropdownOpen, setIsDropdownOpen] = useState(false);

Expand Down Expand Up @@ -87,8 +92,25 @@ const KebabMenu = ({ postId, boardCode, isOwner = false, authorId }: KebabMenuPr
<div className="absolute right-0 top-full z-10 mt-2 w-40 origin-top-right rounded-lg border border-gray-100 bg-white shadow-lg">
<ul className="p-1">
<li>
<ReportPanel idx={postId} blockUserId={authorId} />
<ReportPanel idx={postId} />
</li>
{!isOwner && authorId && (
<li key={"차단하기"}>
<button
onClick={() => {
setIsDropdownOpen(false);
void handleBlockUser({ userId: authorId, postId });
}}
disabled={isBlocking}
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-gray-700 typo-regular-2 hover:bg-k-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="flex-shrink-0">
<Ban className="h-[18px] w-[18px] text-sub-d-500" />
</span>
<span>{"차단하기"}</span>
</button>
</li>
)}
<li key={"URL 복사"}>
<button
onClick={handleCopyUrl}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,25 @@ const PostPageContent = ({ boardCode, postId }: PostPageContentProps) => {
const router = useRouter();
const reportedPostIds = useReportedPostsStore((state) => state.reportedPostIds);
const blockedUserIds = useReportedPostsStore((state) => state.blockedUserIds);
const isReportedPost = reportedPostIds.includes(postId);
const blockedPostIds = useReportedPostsStore((state) => state.blockedPostIds);
const isHiddenPost = reportedPostIds.includes(postId) || blockedPostIds.includes(postId);

const { data: post, isLoading, isError, refetch } = useGetPostDetail(postId);
const isBlockedUserPost = post ? blockedUserIds.includes(post.postFindSiteUserResponse.id) : false;

useEffect(() => {
if (isReportedPost) {
if (isHiddenPost) {
router.replace(`/community/${boardCode}`);
}
}, [boardCode, isReportedPost, router]);
}, [boardCode, isHiddenPost, router]);

useEffect(() => {
if (isBlockedUserPost) {
router.replace(`/community/${boardCode}`);
}
}, [boardCode, isBlockedUserPost, router]);

if (isReportedPost || isBlockedUserPost) {
if (isHiddenPost || isBlockedUserPost) {
return null;
}

Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/app/community/_hooks/useBlockCommunityUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client";

import { Ban } from "lucide-react";
import type { ComponentType, SVGProps } from "react";
import { postBlockUser } from "@/apis/users";
import { showIconToast } from "@/lib/toast/showIconToast";
import { customConfirm } from "@/lib/zustand/useConfirmModalStore";
import useReportedPostsStore from "@/lib/zustand/useReportedPostsStore";

type BlockCommunityUserOptions = {
onBlocked?: () => void;
};

type BlockCommunityUserParams = {
userId?: number;
postId?: number;
nickname?: string;
};

const IconBlock = Ban as ComponentType<SVGProps<SVGSVGElement>>;

const useBlockCommunityUser = ({ onBlocked }: BlockCommunityUserOptions = {}) => {
const { mutateAsync: blockUser, isPending } = postBlockUser();
const blockedUserIds = useReportedPostsStore((state) => state.blockedUserIds);
const addBlockedUser = useReportedPostsStore((state) => state.addBlockedUser);
const addBlockedPost = useReportedPostsStore((state) => state.addBlockedPost);

const handleBlockUser = async ({ userId, postId, nickname }: BlockCommunityUserParams) => {
if (!userId) {
return;
}

if (blockedUserIds.includes(userId)) {
showIconToast("logo", "이미 차단한 사용자입니다.");
onBlocked?.();
return;
}

const userLabel = nickname ? `${nickname}님` : "이 사용자";
const ok = await customConfirm({
title: "사용자 차단",
content: `${userLabel}의 게시글과 댓글이 보이지 않도록 차단할까요?`,
approveMessage: "차단하기",
rejectMessage: "취소",
icon: IconBlock,
});

if (!ok) {
return;
}

addBlockedUser(userId);
if (postId) {
addBlockedPost(postId);
}
onBlocked?.();

try {
await blockUser({
blockedId: userId,
data: {},
});
} catch {
// 서버 차단 동기화 실패와 관계없이 현재 화면에서는 차단 상태를 즉시 반영합니다.
}

showIconToast("logo", "사용자를 차단했습니다.");
};

return {
handleBlockUser,
isBlocking: isPending,
};
};

export default useBlockCommunityUser;
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { usePostReports } from "@/apis/reports";
import { postBlockUser } from "@/apis/users";
import { reportReasons } from "@/constants/report";
import { customConfirm } from "@/lib/zustand/useConfirmModalStore";
import useReportedPostsStore from "@/lib/zustand/useReportedPostsStore";
Expand All @@ -13,19 +12,9 @@ interface UseSelectReportHandlerReturn {
handleReasonSelect: (reason: ReportType) => Promise<void>;
}

interface UseSelectReportHandlerOptions {
blockUserId?: number;
}

const useSelectReportHandler = (
chatId: number,
{ blockUserId }: UseSelectReportHandlerOptions = {},
): UseSelectReportHandlerReturn => {
const useSelectReportHandler = (chatId: number): UseSelectReportHandlerReturn => {
const [selectedReason, setSelectedReason] = useState<ReportType | null>(null);
const { mutateAsync: postReports } = usePostReports();
const { mutateAsync: blockUser } = postBlockUser();
const addBlockedUser = useReportedPostsStore((state) => state.addBlockedUser);
const removeBlockedUser = useReportedPostsStore((state) => state.removeBlockedUser);
const addReportedPost = useReportedPostsStore((state) => state.addReportedPost);
const pathname = usePathname();
const router = useRouter();
Expand All @@ -49,20 +38,6 @@ const useSelectReportHandler = (

if (pathname.startsWith("/community/")) {
addReportedPost(chatId);

if (blockUserId) {
addBlockedUser(blockUserId);

try {
await blockUser({
blockedId: blockUserId,
data: {},
});
} catch {
removeBlockedUser(blockUserId);
return;
}
}
}

router.back();
Expand Down
Loading
Loading