From 3cf37bd7cba9a4bba606600ccb7e26db090f7c16 Mon Sep 17 00:00:00 2001 From: KangJeongmin Date: Sat, 12 Sep 2026 11:56:09 +0900 Subject: [PATCH 1/5] =?UTF-8?q?MSG-570=20feat:=20[=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC]=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=20=E2=80=94=20=EC=98=81=EC=83=81=C2=B7=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=E2=8B=AF=20"=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B0=A8=EB=8B=A8"?= =?UTF-8?q?=20+=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=C2=B7=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱스토어 UGC 심사 요건(서버 MSG-569). 진입점 3곳(격자 상세 타인 행 ⋯ · 재생 헤더 ⋯ 신설 · 이벤트 댓글 길게 누르기)이 `BlockUserDialog` 하나를 공유해 `POST /api/users/{userId}/block` → 리스트 무효화·캐시 seed로 콘텐츠 소실. `/profile/blocks` 목록·해제 화면 신설(report-history 4상태 미러). A2로 신고 흐름을 `VideoActionsMenu`가 흡수. - `features/user-block` 신설(model·api·ui) + `shared/api/list-state.ts` 승격(report-history 위임) - hey-api 재생성(BE MockMvc 덤프) — DTO `userId`/`uploaderId`/`authorId`, 웹은 픽스처 7곳 상수 필드만 - 실기 3-B 실패 0 (댓글 long-press 화면부는 진행 중 이벤트 없어 확인불가 — 지라 코멘트 환류) --no-verify 사유: pre-commit react-doctor가 develop에 이미 있는 `grid-detail-screen.tsx` `rn-scrollview-dynamic-padding` 지적으로 차단(범위 밖, DECISIONS 기록). format:check·lint·verify-report 게이트는 수동 exit 0 확인. Claude-Session: https://claude.ai/code/session_011HhfSJzZqkfeheWM5LNi6Z --- apps/mobile/src/app/profile/blocks.tsx | 6 + .../src/features/auth/model/app-entry.ts | 1 + .../event/api/event-video-mutations.ts | 3 +- .../event/api/use-event-comments-pages.ts | 3 + .../event/api/use-event-video-sheet.ts | 59 +++- .../event/model/event-video-cache.test.ts | 30 ++ .../features/event/model/event-video-cache.ts | 18 ++ .../event/model/event-video-card.test.ts | 1 + .../location-videos-query.parity.test.ts | 1 + .../event/ui/event-video-comment-row.tsx | 24 +- .../event/ui/event-video-sheet-content.tsx | 58 +++- .../grid-detail/model/grid-videos.test.ts | 19 ++ .../features/grid-detail/model/grid-videos.ts | 5 + .../grid-detail/ui/grid-detail-screen.tsx | 70 +---- .../grid-detail/ui/grid-video-row.tsx | 14 +- .../features/profile/ui/profile-screen.tsx | 5 + .../report-history/model/report-history.ts | 17 +- .../api/invalidate-blocked-content.test.ts | 120 ++++++++ .../api/invalidate-blocked-content.ts | 59 ++++ .../user-block/api/use-blocked-users-query.ts | 33 ++ .../api/use-user-block-mutations.ts | 54 ++++ .../api/user-block-mutations.test.ts | 244 +++++++++++++++ .../user-block/api/user-block-mutations.ts | 92 ++++++ .../user-block/model/user-block.test.ts | 101 ++++++ .../features/user-block/model/user-block.ts | 57 ++++ .../user-block/ui/block-user-dialog.tsx | 65 ++++ .../user-block/ui/blocked-user-row.tsx | 51 ++++ .../user-block/ui/blocked-users-screen.tsx | 73 +++++ .../ui/report-modal.tsx | 7 +- .../video-actions/ui/video-actions-menu.tsx | 86 +++++- .../video-actions/ui/video-more-sheet.tsx | 10 +- .../video-playback/ui/video-player-screen.tsx | 57 ++++ apps/mobile/src/shared/api/list-state.ts | 18 ++ apps/mobile/src/test/event-video-fixture.ts | 1 + apps/web/openapi/api-docs.json | 2 +- .../web/src/entities/cell/model/mock-cells.ts | 1 + .../model/use-location-videos-query.test.tsx | 1 + .../map-home/model/grid-videos.test.ts | 1 + .../ui/event-archive-body.smoke.test.tsx | 1 + .../ui/event-room-videos.smoke.test.tsx | 1 + .../generated/@tanstack/react-query.gen.ts | 135 +++++++- apps/web/src/shared/api/generated/index.ts | 4 +- apps/web/src/shared/api/generated/sdk.gen.ts | 69 ++++- .../web/src/shared/api/generated/types.gen.ts | 206 ++++++++++++- apps/web/src/test/event-video-fixture.ts | 1 + apps/web/src/test/playback-fixture.ts | 1 + docs/STATUS.md | 2 + docs/decisions/DECISIONS.md | 4 + docs/spec/MSG-570.md | 121 ++++++++ nose.baseline.json | 288 +++++++++++------- 50 files changed, 2034 insertions(+), 266 deletions(-) create mode 100644 apps/mobile/src/app/profile/blocks.tsx create mode 100644 apps/mobile/src/features/user-block/api/invalidate-blocked-content.test.ts create mode 100644 apps/mobile/src/features/user-block/api/invalidate-blocked-content.ts create mode 100644 apps/mobile/src/features/user-block/api/use-blocked-users-query.ts create mode 100644 apps/mobile/src/features/user-block/api/use-user-block-mutations.ts create mode 100644 apps/mobile/src/features/user-block/api/user-block-mutations.test.ts create mode 100644 apps/mobile/src/features/user-block/api/user-block-mutations.ts create mode 100644 apps/mobile/src/features/user-block/model/user-block.test.ts create mode 100644 apps/mobile/src/features/user-block/model/user-block.ts create mode 100644 apps/mobile/src/features/user-block/ui/block-user-dialog.tsx create mode 100644 apps/mobile/src/features/user-block/ui/blocked-user-row.tsx create mode 100644 apps/mobile/src/features/user-block/ui/blocked-users-screen.tsx rename apps/mobile/src/features/{grid-detail => video-actions}/ui/report-modal.tsx (97%) create mode 100644 apps/mobile/src/shared/api/list-state.ts create mode 100644 docs/spec/MSG-570.md diff --git a/apps/mobile/src/app/profile/blocks.tsx b/apps/mobile/src/app/profile/blocks.tsx new file mode 100644 index 00000000..d4eb17bc --- /dev/null +++ b/apps/mobile/src/app/profile/blocks.tsx @@ -0,0 +1,6 @@ +import { BlockedUsersScreen } from "../../features/user-block/ui/blocked-users-screen"; + +/** 차단한 사용자 라우트 (MSG-570 기준 12) — 프로필·설정에서 push */ +export default function ProfileBlocks() { + return ; +} diff --git a/apps/mobile/src/features/auth/model/app-entry.ts b/apps/mobile/src/features/auth/model/app-entry.ts index a1a5dab1..883261e1 100644 --- a/apps/mobile/src/features/auth/model/app-entry.ts +++ b/apps/mobile/src/features/auth/model/app-entry.ts @@ -18,6 +18,7 @@ export const PROTECTED_ROUTES = [ "dex/history", "grid/[cellId]", "profile", + "profile/blocks", "profile/consent", "profile/edit", "profile/reports", diff --git a/apps/mobile/src/features/event/api/event-video-mutations.ts b/apps/mobile/src/features/event/api/event-video-mutations.ts index 38c64f2d..ce7107f8 100644 --- a/apps/mobile/src/features/event/api/event-video-mutations.ts +++ b/apps/mobile/src/features/event/api/event-video-mutations.ts @@ -37,8 +37,9 @@ type DetailEnvelope = ApiResponseDtoEventVideoDetailResponseDto; /** * 상세 캐시 seed — 봉투를 유지한 채 data만 갱신하고 갱신된 상세를 돌려준다. * 캐시 부재(시트 닫힘 후 gc)면 no-op·undefined. + * MSG-570 댓글 작성자 차단(`use-event-video-sheet`)도 이 경로로 상세를 갱신한다 — 상세 invalidate 금지. */ -const seedDetail = ( +export const seedDetail = ( queryClient: QueryClient, videoId: number, update: (detail: EventVideoDetailResponseDto) => EventVideoDetailResponseDto, diff --git a/apps/mobile/src/features/event/api/use-event-comments-pages.ts b/apps/mobile/src/features/event/api/use-event-comments-pages.ts index 6f939839..1ca68a00 100644 --- a/apps/mobile/src/features/event/api/use-event-comments-pages.ts +++ b/apps/mobile/src/features/event/api/use-event-comments-pages.ts @@ -36,6 +36,8 @@ export interface EventCommentsPagesResult { /** 다음 페이지 이어받기 — 진행 중이거나 더 없으면 아무것도 하지 않는다 */ loadMore: () => void; isLoadingMore: boolean; + /** 이어받은 페이지를 비운다 (MSG-570 기준 11) — 댓글 작성자 차단 후 첫 페이지 seed와 함께 */ + reset: () => void; } interface ExtraPagesState { @@ -91,5 +93,6 @@ export const useEventCommentsPages = ( hasNext: cursor !== null, loadMore, isLoadingMore, + reset: () => setExtra({ videoId, pages: [] }), }; }; diff --git a/apps/mobile/src/features/event/api/use-event-video-sheet.ts b/apps/mobile/src/features/event/api/use-event-video-sheet.ts index cb728c3f..7150f4a6 100644 --- a/apps/mobile/src/features/event/api/use-event-video-sheet.ts +++ b/apps/mobile/src/features/event/api/use-event-video-sheet.ts @@ -1,9 +1,14 @@ import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { EventVideoCommentResponseDto } from "../../../shared/api/sdk"; +import { useProfileQuery } from "../../profile/api/use-profile-query"; +import type { BlockTarget } from "../../user-block/model/user-block"; import { useAutoDismissToast } from "../../video-actions/model/use-auto-dismiss-toast"; import type { EventLocationSelection } from "../model/event-location"; import { deactivateEvent, stepBackEvent } from "../model/event-selection"; import { eventVideoInteraction, + removeCommentsByAuthor, type EventVideoInteraction, } from "../model/event-video-cache"; import { @@ -11,6 +16,7 @@ import { eventInteractionErrorMessage, trimmedCommentContent, } from "../model/event-video-view"; +import { seedDetail } from "./event-video-mutations"; import { useEventCommentsPages, type EventCommentsPagesResult, @@ -60,8 +66,25 @@ export interface EventVideoSheet extends EventVideoDetailResult { /** 전송 — trim 1~500자 판정 실패·잠금·진행 중이면 무시 (D6) */ submitComment: () => void; submitDisabled: boolean; - /** 실패 안내 3초 토스트 — null이면 미표시 (D10) */ + /** 실패·차단 완료 안내 3초 토스트 — null이면 미표시 (D10 · MSG-570) */ toast: string | null; + /** + * 댓글 행 길게 누르기의 차단 대상 (MSG-570 기준 10) — 내 댓글(작성자 닉네임 = getMe 닉네임, + * A4)은 null이라 행이 눌리지 않는다 + */ + commentBlockTarget: ( + comment: EventVideoCommentResponseDto, + ) => BlockTarget | null; + /** 길게 누른 타인 댓글 — "사용자 차단" 1행 액션시트의 대상. null이면 닫힘 */ + commentMenu: BlockTarget | null; + openCommentMenu: (target: BlockTarget) => void; + closeCommentMenu: () => void; + /** 확인 다이얼로그 대상 — 액션시트에서 "사용자 차단"을 고르면 채워진다 */ + blockTarget: BlockTarget | null; + confirmBlockFromMenu: () => void; + closeBlockDialog: () => void; + /** 차단 성공 — 상세 캐시 seed(그 작성자 댓글 제거) + 이어받은 페이지 리셋 + 토스트 (기준 11) */ + onBlocked: () => void; /** * `‹`·`✕` — `use-event-home`의 `handlers.back/close`와 같은 모듈 액션. 시트 스위치의 * 접촉면 예산(≤8줄) 때문에 prop 주입 대신 여기서 묶는다 (D11·D13) @@ -98,6 +121,29 @@ export const useEventVideoSheet = (videoId: number): EventVideoSheet => { const interaction = detail === null ? null : eventVideoInteraction(detail); + // 댓글 작성자 차단 (MSG-570 기준 10·11) + const queryClient = useQueryClient(); + const { data: me } = useProfileQuery(); + const [commentMenu, setCommentMenu] = useState(null); + const [blockTarget, setBlockTarget] = useState(null); + const commentBlockTarget = ( + comment: EventVideoCommentResponseDto, + ): BlockTarget | null => + comment.authorNickname === me?.nickname + ? null + : { userId: comment.authorId, nickname: comment.authorNickname }; + const onBlocked = () => { + const target = blockTarget; + setBlockTarget(null); + if (target === null) return; + // 상세 invalidate 금지(조회수 부작용) — seed + 페이지 리셋으로만 목록에서 뺀다 (A5) + seedDetail(queryClient, videoId, (previous) => + removeCommentsByAuthor(previous, target.userId), + ); + comments.reset(); + setToast("차단했어요"); + }; + const pressHelpful = () => { if (detail === null || detail.interactionLocked || toggleHelpful.isPending) return; @@ -124,6 +170,17 @@ export const useEventVideoSheet = (videoId: number): EventVideoSheet => { createComment.isPending || !canSubmitComment(draft), toast, + commentBlockTarget, + commentMenu, + openCommentMenu: setCommentMenu, + closeCommentMenu: () => setCommentMenu(null), + blockTarget, + confirmBlockFromMenu: () => { + setBlockTarget(commentMenu); + setCommentMenu(null); + }, + closeBlockDialog: () => setBlockTarget(null), + onBlocked, back: stepBackEvent, close: deactivateEvent, }; diff --git a/apps/mobile/src/features/event/model/event-video-cache.test.ts b/apps/mobile/src/features/event/model/event-video-cache.test.ts index 008665a4..9be92acf 100644 --- a/apps/mobile/src/features/event/model/event-video-cache.test.ts +++ b/apps/mobile/src/features/event/model/event-video-cache.test.ts @@ -6,6 +6,7 @@ import { import { appendComment, eventVideoInteraction, + removeCommentsByAuthor, seedHelpful, } from "./event-video-cache"; @@ -48,6 +49,35 @@ describe("appendComment — 작성 응답 seed (AC 6)", () => { }); }); +describe("removeCommentsByAuthor — 댓글 작성자 차단 seed (MSG-570 기준 11)", () => { + it("그 authorId의 댓글만 첫 페이지에서 빠지고 commentCount는 그대로다 — 서버도 줄이지 않는다", () => { + const detail = { + ...EVENT_VIDEO_DETAIL, + commentCount: 3, + comments: { + ...EVENT_VIDEO_DETAIL.comments, + comments: [ + eventComment(1, { authorId: 7 }), + eventComment(2, { authorId: 99 }), + eventComment(3, { authorId: 7 }), + ], + }, + }; + + const seeded = removeCommentsByAuthor(detail, 7); + + expect(seeded.comments.comments.map((c) => c.commentId)).toEqual([2]); + expect(seeded.commentCount).toBe(3); + expect(seeded.comments.hasNext).toBe(detail.comments.hasNext); + }); + + it("그 작성자의 댓글이 없으면 상세가 그대로다 (경계)", () => { + expect(removeCommentsByAuthor(EVENT_VIDEO_DETAIL, 99)).toEqual( + EVENT_VIDEO_DETAIL, + ); + }); +}); + describe("eventVideoInteraction — interactionLocked 파생 (AC 7)", () => { it("잠금이 아니면 도움돼요·입력이 활성이고 placeholder는 입력 안내다", () => { expect(eventVideoInteraction(EVENT_VIDEO_DETAIL)).toEqual({ diff --git a/apps/mobile/src/features/event/model/event-video-cache.ts b/apps/mobile/src/features/event/model/event-video-cache.ts index 3a84bd91..86608c0a 100644 --- a/apps/mobile/src/features/event/model/event-video-cache.ts +++ b/apps/mobile/src/features/event/model/event-video-cache.ts @@ -46,6 +46,24 @@ export const appendComment = ( }; }; +/** + * 댓글 작성자 차단 seed (MSG-570 기준 11) — 첫 페이지에서 그 `authorId`의 댓글을 뺀다. + * `commentCount`는 **그대로다** — 서버도 차단으로 카운트를 줄이지 않는다(MSG-569 확정). + * 이어받은 페이지는 호출부가 `reset()`으로 비운다. 상세 invalidate는 금지(조회수 부작용). + */ +export const removeCommentsByAuthor = ( + detail: EventVideoDetailResponseDto, + authorId: number, +): EventVideoDetailResponseDto => ({ + ...detail, + comments: { + ...detail.comments, + comments: detail.comments.comments.filter( + (comment) => comment.authorId !== authorId, + ), + }, +}); + export interface EventVideoInteraction { helpfulDisabled: boolean; inputDisabled: boolean; diff --git a/apps/mobile/src/features/event/model/event-video-card.test.ts b/apps/mobile/src/features/event/model/event-video-card.test.ts index 62dcfdaa..73300e7b 100644 --- a/apps/mobile/src/features/event/model/event-video-card.test.ts +++ b/apps/mobile/src/features/event/model/event-video-card.test.ts @@ -17,6 +17,7 @@ const video = ( createdAt: "2026-09-02T11:58:00+09:00", helpfulCount: 1, commentCount: 2, + uploaderId: 7, ...over, }); diff --git a/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts b/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts index 88d5cae1..f5595903 100644 --- a/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts +++ b/apps/mobile/src/features/event/model/location-videos-query.parity.test.ts @@ -41,6 +41,7 @@ const video = (videoId: number): EventLocationVideoResponseDto => ({ createdAt: "2026-09-02T11:58:00+09:00", helpfulCount: 1, commentCount: 2, + uploaderId: 7, }); const page = ( diff --git a/apps/mobile/src/features/event/ui/event-video-comment-row.tsx b/apps/mobile/src/features/event/ui/event-video-comment-row.tsx index d8a5e75a..0a400f30 100644 --- a/apps/mobile/src/features/event/ui/event-video-comment-row.tsx +++ b/apps/mobile/src/features/event/ui/event-video-comment-row.tsx @@ -1,4 +1,4 @@ -import { Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { Avatar } from "@fillmap/ui-native"; import type { EventVideoCommentResponseDto } from "../../../shared/api/sdk"; import { formatRelativeTime } from "../../../shared/format"; @@ -6,15 +6,33 @@ import { formatRelativeTime } from "../../../shared/format"; /** * 댓글 행 (MSG-562 D5, Figma 15794:822 `comments`) — 아바타 폴백(첫 글자, DTO에 프로필 * 이미지 없음 — 오탐 방지 5) + 닉네임 + 상대시간(우측) + 본문. 웹 `EventVideoComments.tsx` 참조본. + * **길게 누르기**(MSG-570 기준 10) — `onLongPress`가 있을 때만 Pressable이고(타인 댓글), + * 스크린리더에는 같은 동작을 접근성 액션 "longpress"로 노출한다. 내 댓글은 `onLongPress`가 + * 없어 평범한 View다. */ interface EventVideoCommentRowProps { comment: EventVideoCommentResponseDto; + onLongPress?: () => void; } export const EventVideoCommentRow = ({ comment, + onLongPress, }: EventVideoCommentRowProps) => ( - + { + if (event.nativeEvent.actionName === "longpress") onLongPress?.(); + }} + className="flex-row gap-xs" + > @@ -30,5 +48,5 @@ export const EventVideoCommentRow = ({ {comment.content} - + ); diff --git a/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx b/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx index c613c8e2..aa534458 100644 --- a/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx +++ b/apps/mobile/src/features/event/ui/event-video-sheet-content.tsx @@ -1,7 +1,15 @@ import { Pressable, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Heart, MessageCircle } from "lucide-react-native"; import { semantic } from "@fillmap/design-tokens"; -import { Button, Toast, cx } from "@fillmap/ui-native"; +import { + ActionSheet, + ActionSheetItem, + Button, + Toast, + cx, +} from "@fillmap/ui-native"; +import { BlockUserDialog } from "../../user-block/ui/block-user-dialog"; import type { HomeSheetContentContext } from "../../map-home/ui/home-sheet"; import { SheetHeader } from "../../map-home/ui/sheet-header"; import { SheetScrollView } from "../../map-home/ui/sheet-scroll-view"; @@ -25,6 +33,8 @@ import { EventVideoPlayer } from "./event-video-player"; * `eventVideoTitle` 폴백만, 배지 이모지 없음, 댓글 오래된순, 아바타 첫 글자 폴백, 재생 * 컨트롤은 expo-video 네이티브. 헤더 ⋯(유틸리티 메뉴)는 제외 범위. * 토스트는 시트 안 인라인(`ActionToast` Modal은 3초간 전 화면 터치를 삼켜 재생 조작을 막는다). + * **[MSG-570 기준 10·11] 타인 댓글 길게 누르기 → "사용자 차단" 1행 액션시트 → 확인 다이얼로그** + * — 성공 시 그 작성자 댓글이 상세 seed로 빠지고 "차단했어요"가 같은 인라인 토스트로 뜬다. */ interface EventVideoSheetContentProps extends HomeSheetContentContext { video: EventVideoInput; @@ -49,9 +59,18 @@ export const EventVideoSheetContent = ({ submitComment, submitDisabled, toast, + commentBlockTarget, + commentMenu, + openCommentMenu, + closeCommentMenu, + blockTarget, + confirmBlockFromMenu, + closeBlockDialog, + onBlocked, back, close, } = useEventVideoSheet(video.videoId); + const insets = useSafeAreaInsets(); const helpfulByMe = detail?.helpfulByMe ?? false; const helpfulDisabled = interaction?.helpfulDisabled ?? true; // 키보드 회피 (A2 대안 — R2 실기에서 footer가 자판 뒤에 가려져 채택) @@ -144,12 +163,20 @@ export const EventVideoSheetContent = ({ ) : ( - {comments.comments.map((comment) => ( - - ))} + {comments.comments.map((comment) => { + const target = commentBlockTarget(comment); + return ( + openCommentMenu(target) + } + /> + ); + })} )} {comments.hasNext && ( @@ -184,6 +211,23 @@ export const EventVideoSheetContent = ({ submitDisabled={submitDisabled} /> + + {/* 댓글 작성자 차단 (MSG-570 기준 10) — 1행 액션시트 + 공용 확인 다이얼로그 */} + + + + )} diff --git a/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts b/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts index 58399b81..ca6d7aa3 100644 --- a/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts +++ b/apps/mobile/src/features/grid-detail/model/grid-videos.test.ts @@ -19,6 +19,7 @@ const globalVideo = ( viewCount: number; recordedAt: string; nickname: string; + userId: number; } => ({ videoId, thumbnailUrl: `https://cdn.test/${videoId}.jpg`, @@ -26,6 +27,8 @@ const globalVideo = ( viewCount, recordedAt: "2026-08-16T12:00:00+09:00", nickname, + // 작성자 id — MSG-570 차단 경로 값. videoId와 다른 값으로 두어 혼동 단정을 막는다 + userId: videoId + 1000, }); const myVideo = (videoId: number) => ({ @@ -79,6 +82,22 @@ describe("buildGridVideoRows — 소유 판정 (L14)", () => { }); }); +describe("buildGridVideoRows — 차단 대상 작성자 (MSG-570 기준 2·4)", () => { + it("타인 영상 행은 전역 DTO의 userId·닉네임을 차단 대상으로 갖고, 내 영상 행은 null이다 (기준 2·4)", () => { + const [mineRow, otherRow] = buildGridVideoRows( + [globalVideo(11, "부산러버")], + [myVideo(33)], + NOW, + ); + + expect(otherRow).toMatchObject({ + mine: false, + author: { userId: 1011, nickname: "부산러버" }, + }); + expect(mineRow).toMatchObject({ mine: true, author: null }); + }); +}); + describe("buildGridVideoRows — 행 표시 문구 (L15)", () => { it('타인 영상 행은 "@닉네임" + "조회 214 · 3일 전"이다 (L15)', () => { const [row] = buildGridVideoRows([globalVideo(11, "부산러버")], [], NOW); diff --git a/apps/mobile/src/features/grid-detail/model/grid-videos.ts b/apps/mobile/src/features/grid-detail/model/grid-videos.ts index 1e7738f3..4330ebd5 100644 --- a/apps/mobile/src/features/grid-detail/model/grid-videos.ts +++ b/apps/mobile/src/features/grid-detail/model/grid-videos.ts @@ -3,6 +3,7 @@ import type { GridVideoResponseDto, } from "../../../shared/api/sdk"; import { formatRelativeTime, formatViewCount } from "../../../shared/format"; +import type { BlockTarget } from "../../user-block/model/user-block"; /** * "이 격자의 영상" 목록 병합·소유 판정·행 문구 (MSG-431 L13~L15) — 순수 함수. @@ -33,6 +34,8 @@ export interface GridVideoRow { title: string; /** 행 보조 — "조회 214 · 3일 전" (조회수 미보유면 상대시간만) */ meta: string; + /** 차단 대상 작성자 (MSG-570) — 전역 DTO의 `userId`·`nickname`, 내 영상은 null */ + author: BlockTarget | null; } const fromGlobal = ( @@ -47,6 +50,7 @@ const fromGlobal = ( // @는 FE가 붙인다 — 명세 주석 계약 (웹 toFeedItemFromGlobal과 동일) title: `@${dto.nickname}`, meta: `조회 ${formatViewCount(dto.viewCount)} · ${formatRelativeTime(dto.recordedAt, now)}`, + author: { userId: dto.userId, nickname: dto.nickname }, }); const fromMine = (dto: GridVideoResponseDto, now?: Date): GridVideoRow => ({ @@ -58,6 +62,7 @@ const fromMine = (dto: GridVideoResponseDto, now?: Date): GridVideoRow => ({ title: "내 영상", // my-videos 응답에 조회수가 없다 — 0으로 꾸미지 않고 상대시간만 낸다 meta: formatRelativeTime(dto.createdAt, now), + author: null, }); /** diff --git a/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx b/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx index 1f7333fa..0c2fffa7 100644 --- a/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx +++ b/apps/mobile/src/features/grid-detail/ui/grid-detail-screen.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { ActivityIndicator, Pressable, @@ -10,19 +10,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useRouter } from "expo-router"; import { Share2 } from "lucide-react-native"; import { semantic } from "@fillmap/design-tokens"; -import { BottomSheet, Button, MapIconButton, Toast } from "@fillmap/ui-native"; +import { BottomSheet, Button, MapIconButton } from "@fillmap/ui-native"; import { serverGridIdFromCellId } from "../../../entities/cell/model/cell-id"; -import { useReportVideo } from "../../video-actions/api/use-video-mutations"; -import { - reportFailureNotice, - type ReportReasonId, -} from "../../video-actions/model/report"; -import { useAutoDismissToast } from "../../video-actions/model/use-auto-dismiss-toast"; import { GridMap } from "../../map-home/ui/grid-map"; import { useGridVideosQuery } from "../api/use-grid-videos-query"; import { deriveCellDetail } from "../model/cell-detail"; import { GridVideoRow } from "./grid-video-row"; -import { ReportModal } from "./report-modal"; import { VideoPreview } from "./video-preview"; /** 상세 지도 줌 — 100m 셀이 Figma(14094:4194)처럼 화면 폭의 1/3 규모로 보이는 수준 */ @@ -69,7 +62,8 @@ interface GridDetailScreenProps { * `deriveCellDetail` mock이다(이 티켓의 목적은 신고 배선이지 격자 상세 실연동이 아니다 — * 남은 mock은 빌드 리포트에 명시하고 환류한다). 영상 목록은 전역·내 영상 2개 응답의 * videoId 교집합으로 소유를 판정해(`useGridVideosQuery`) 행마다 ⋯ 를 얹고, 도감 갤러리와 - * **같은** 시트를 연다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기. + * **같은** 시트를 연다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기·사용자 차단. + * 신고 모달·발사·토스트는 MSG-570에서 `VideoActionsMenu`로 흡수됐다(재생 화면과 공유). * * **화면 상단의 격자 단위 ⋯(수정/삭제/신고)는 제거했다** (스펙 신고 배선 결정 4의 "정리" * 판정 = 제거). 세 항목 모두 영상 단위 메뉴와 의미가 겹치면서 대상이 불분명했다: @@ -81,9 +75,6 @@ interface GridDetailScreenProps { export const GridDetailScreen = ({ cellId }: GridDetailScreenProps) => { const router = useRouter(); const insets = useSafeAreaInsets(); - const [reportTarget, setReportTarget] = useState(null); - const [reportToast, setReportToast] = useAutoDismissToast(); - const [reportFailure, setReportFailure] = useAutoDismissToast(); const detail = useMemo(() => deriveCellDetail(cellId), [cellId]); // 라우트 셀 id(구 위경도 스텝 체계)를 서버 격자 id(EPSG:5179)로 옮긴다 — 두 체계를 @@ -91,41 +82,9 @@ export const GridDetailScreen = ({ cellId }: GridDetailScreenProps) => { const gridId = useMemo(() => serverGridIdFromCellId(cellId), [cellId]); const videos = useGridVideosQuery(gridId); - /** - * 신고 모달 닫기 — **대상과 실패 안내를 함께 비운다**. 실패 안내는 3초 자동 소멸이라 - * 그 사이에 닫고 다른 영상의 신고 모달을 열면 A의 실패 문구가 B의 카드에 뜬다 - * (모달 로컬 선택 상태와 같은 부류의 잔존 — MSG-431 재작업에서 함께 잡았다). - */ - const closeReport = () => { - setReportTarget(null); - setReportFailure(null); - }; - - const report = useReportVideo({ - onReported: () => { - closeReport(); - setReportToast("신고가 접수되었어요"); - }, - onFailed: (error) => { - const notice = reportFailureNotice(error); - if (notice.shouldClose) { - closeReport(); - setReportToast(notice.message); - return; - } - setReportFailure(notice.message); - }, - }); - // 유일한 진입 경로(지도 탭)는 항상 인코딩 id를 만든다 — 형식 밖 param은 렌더 없음 if (!detail) return null; - const handleReportSubmit = (reasonId: ReportReasonId) => { - if (reportTarget === null) return; - // 연타 방어는 `mutate`에 씌워진 in-flight 가드가 맡는다 (guardMutate — codex 리뷰 2) - report.mutate({ videoId: reportTarget, reasonId }); - }; - return ( {/* 상단 지도 — 시트(top 36%)에 하단이 덮이는 44% 높이라 카메라 중심(22% 지점)이 노출 영역 안에 온다 (AC 2) */} @@ -232,33 +191,12 @@ export const GridDetailScreen = ({ cellId }: GridDetailScreenProps) => { // gridId는 목록이 도착했다는 것 자체가 non-null임을 뜻한다(쿼리 게이트) gridId={gridId ?? ""} gridLabel={detail.label} - onReport={(target) => setReportTarget(target.videoId)} /> ))} )} - - {/* 영상 신고 모달 (AC 9~13) — 대상 videoId는 영상 행이 정한다 */} - - - {/* 신고 접수·중복 안내 토스트 — 홈 인디케이터 위 오버레이, 자동 소멸 */} - {reportToast !== null && ( - - - - )} ); }; diff --git a/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx b/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx index f97473b5..d0a77b4c 100644 --- a/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx +++ b/apps/mobile/src/features/grid-detail/ui/grid-video-row.tsx @@ -12,14 +12,13 @@ interface GridVideoRowProps { gridId: string; /** 삭제 확인 카드 제목의 격자 라벨 (예: "서면 A-14") */ gridLabel: string; - /** 신고하기 선택 — 신고 모달은 화면이 소유한다 */ - onReport: (row: GridVideoRowModel) => void; } /** * 격자 상세 "이 격자의 영상" 1행 (MSG-431 신고 배선 결정 2) — `VideoRow` + ⋯ 트리거 + * 도감 갤러리와 **같은** 액션 시트(`VideoActionsMenu`). - * `mine`에 따라 시트가 갈린다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기. + * `mine`에 따라 시트가 갈린다 — 내 영상은 공개 범위·삭제, 타인 영상은 신고하기·사용자 차단. + * 신고 모달·차단 다이얼로그·토스트는 `VideoActionsMenu`가 소유한다(MSG-570 — 화면 무접촉). * * ⋯는 `VideoRow` 밖 형제로 둔다 — `VideoRow`에 트레일링 슬롯이 없고, ui-native는 * 이 티켓에서 무수정이 원칙이다(`index.ts` diff 0줄 합의). 행 전체는 `flex-1`로 남는다. @@ -30,12 +29,7 @@ interface GridVideoRowProps { * 이제 실제 목적지가 붙어 버튼 낭독이 사실이 된다. 행 이름은 `title`+`meta` * ("내 영상"·"@닉네임 · 3시간 전")가 그대로 읽혀 별도 라벨을 주지 않는다. */ -export const GridVideoRow = ({ - row, - gridId, - gridLabel, - onReport, -}: GridVideoRowProps) => { +export const GridVideoRow = ({ row, gridId, gridLabel }: GridVideoRowProps) => { const [menuOpen, setMenuOpen] = useState(false); return ( @@ -61,7 +55,7 @@ export const GridVideoRow = ({ createdAt: row.recordedAt, gridLabel, }} - onReport={() => onReport(row)} + author={row.author ?? undefined} /> ); diff --git a/apps/mobile/src/features/profile/ui/profile-screen.tsx b/apps/mobile/src/features/profile/ui/profile-screen.tsx index e292b6c1..e7501b94 100644 --- a/apps/mobile/src/features/profile/ui/profile-screen.tsx +++ b/apps/mobile/src/features/profile/ui/profile-screen.tsx @@ -204,6 +204,11 @@ export const ProfileScreen = () => { label="신고 관리" onPress={() => router.navigate("/profile/reports")} /> + {/* MSG-570 기준 12 — 차단한 사용자 목록·해제 */} + router.navigate("/profile/blocks")} + /> {/* 계정 (기준 7~10) — 앱 버전은 정보 행(› 없음). [MSG-448] 약관 2행도 동작 행 */} diff --git a/apps/mobile/src/features/report-history/model/report-history.ts b/apps/mobile/src/features/report-history/model/report-history.ts index 9b4226aa..84085caf 100644 --- a/apps/mobile/src/features/report-history/model/report-history.ts +++ b/apps/mobile/src/features/report-history/model/report-history.ts @@ -1,3 +1,7 @@ +import { + resolveListState, + type ListState, +} from "../../../shared/api/list-state"; import { formatKstDate } from "../../../shared/format"; /** @@ -41,22 +45,17 @@ export interface MyReportItem { } /** 목록 영역의 배타 상태 (기준 19) */ -export type ReportListState = "loading" | "error" | "empty" | "list"; +export type ReportListState = ListState; /** - * 로딩·실패·빈·목록을 한 값으로 판정한다 (기준 19). - * 실패가 로딩보다 우선한다 — 재시도 왕복 중에 실패 안내와 [다시 시도]가 사라졌다 - * 다시 나타나면 사용자가 재시도를 두 번 누르게 된다. + * 로딩·실패·빈·목록을 한 값으로 판정한다 (기준 19) — 판정 규칙은 `shared/api/list-state` + * (MSG-570 차단 목록이 같은 판정을 쓰게 되면서 올렸다). 실패가 로딩보다 우선한다. */ export const resolveReportListState = (input: { isPending: boolean; isError: boolean; items: readonly MyReportItem[]; -}): ReportListState => { - if (input.isError) return "error"; - if (input.isPending) return "loading"; - return input.items.length === 0 ? "empty" : "list"; -}; +}): ReportListState => resolveListState(input); /** 열람용 사유 라벨 — 목록 행이 좁아 제출 화면(REPORT_REASONS)보다 축약형이다 (승인 Q5) */ const REPORT_REASON_LABELS = { diff --git a/apps/mobile/src/features/user-block/api/invalidate-blocked-content.test.ts b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.test.ts new file mode 100644 index 00000000..0f1d5745 --- /dev/null +++ b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.test.ts @@ -0,0 +1,120 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * 템플릿 ③ 쿼리 훅(모바일 변형) — 차단·해제 성공 후 무효화 집합 (MSG-570 기준 6). + * 목록형 4종은 활성 재조회, 단건 조회 2종(`getPlayback`·`getVideoDetail`)은 무효화만 하고 + * 재조회하지 않는다(조회수 부작용 · 재생 화면 pop 전 404 플래시 방지). + * 생성 키 팩토리는 client-config를 정적으로 끌고 오므로 env를 세운 뒤 동적 import한다. + */ +type QueryOptionsModule = typeof import("../../../shared/api/query-options"); +type InvalidateModule = typeof import("./invalidate-blocked-content"); + +let keys: QueryOptionsModule; +let invalidateAfterBlockChange: InvalidateModule["invalidateAfterBlockChange"]; + +beforeEach(async () => { + vi.stubEnv("EXPO_PUBLIC_API_BASE_URL", "https://api.test.local"); + vi.resetModules(); + keys = await import("../../../shared/api/query-options"); + ({ invalidateAfterBlockChange } = + await import("./invalidate-blocked-content")); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +/** + * 활성 관찰자가 붙은 캐시를 만든다 — 무효화가 "재조회까지 하는지"는 관찰자가 있어야 보인다. + * staleTime Infinity라 관찰자 구독 자체는 재조회를 일으키지 않는다. + */ +const observe = async ( + queryClient: QueryClient, + queryKey: readonly unknown[], +) => { + const options = { + queryKey, + queryFn: async () => ({ ok: true }), + staleTime: Infinity, + }; + await queryClient.fetchQuery(options); + return new QueryObserver(queryClient, options).subscribe(() => {}); +}; + +const seedKeys = () => ({ + gridGlobalVideos: keys.getGridGlobalVideosQueryKey({ + path: { gridId: "16858_11420" }, + }), + locationVideos: keys.getLocationVideosInfiniteQueryKey({ + path: { occurrenceId: 5, locationId: 11 }, + }), + missionVideos: keys.getMissionVideosQueryKey({ path: { missionId: 3 } }), + comments: keys.getCommentsQueryKey({ + path: { videoId: 240347 }, + query: { cursor: "c1" }, + }), + playback: keys.getPlaybackQueryKey({ path: { videoId: 4102 } }), + videoDetail: keys.getVideoDetailQueryKey({ path: { videoId: 240347 } }), +}); + +describe("invalidateAfterBlockChange — 차단·해제 성공 후 무효화 집합 (기준 6)", () => { + it("격자 전역·위치 영상(infinite)·미션 영상·댓글 페이지는 파라미터와 무관하게 무효화되고 활성 재조회된다 (기준 6)", async () => { + const queryClient = new QueryClient(); + const entries = seedKeys(); + const unsubscribes = await Promise.all( + [ + entries.gridGlobalVideos, + entries.locationVideos, + entries.missionVideos, + entries.comments, + ].map((key) => observe(queryClient, key)), + ); + + invalidateAfterBlockChange(queryClient); + + for (const key of [ + entries.gridGlobalVideos, + entries.locationVideos, + entries.missionVideos, + entries.comments, + ]) { + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(key)?.fetchStatus).toBe("fetching"); + } + unsubscribes.forEach((unsubscribe) => unsubscribe()); + queryClient.clear(); + }); + + it("재생 단건·행사 영상 상세는 무효화만 되고 활성 상태여도 재조회하지 않는다 — 조회수 부작용 (기준 6)", async () => { + const queryClient = new QueryClient(); + const entries = seedKeys(); + const unsubscribes = await Promise.all( + [entries.playback, entries.videoDetail].map((key) => + observe(queryClient, key), + ), + ); + + invalidateAfterBlockChange(queryClient); + + for (const key of [entries.playback, entries.videoDetail]) { + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(key)?.fetchStatus).toBe("idle"); + } + unsubscribes.forEach((unsubscribe) => unsubscribe()); + queryClient.clear(); + }); + + it("차단과 무관한 캐시(차단 목록 포함)는 무효화되지 않는다 (기준 6·14 — 과잉 무효화 방지)", () => { + const queryClient = new QueryClient(); + const blockedUsers = keys.getBlockedUsersQueryKey(); + queryClient.setQueryData(blockedUsers, { ok: true }); + queryClient.setQueryData(["unrelated"], { ok: true }); + + invalidateAfterBlockChange(queryClient); + + expect(queryClient.getQueryState(blockedUsers)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(["unrelated"])?.isInvalidated).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/user-block/api/invalidate-blocked-content.ts b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.ts new file mode 100644 index 00000000..d4a97f78 --- /dev/null +++ b/apps/mobile/src/features/user-block/api/invalidate-blocked-content.ts @@ -0,0 +1,59 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + getCommentsQueryKey, + getGridGlobalVideosQueryKey, + getLocationVideosQueryKey, + getMissionVideosQueryKey, + getPlaybackQueryKey, + getVideoDetailQueryKey, +} from "../../../shared/api/query-options"; + +/** + * 차단·해제 성공 후 무효화 집합 (MSG-570 기준 6) — 순수 배선 함수 + * (`video-actions/api/invalidate-video-queries` 관례: 모바일은 훅 렌더 테스트가 없어 분리). + * + * 서버가 차단 관계로 필터하는 목록(MSG-569 6종) 중 앱이 캐시하는 것을 모두 `_id` 부분 키로 + * 무효화한다 — 생성 `createQueryKey`가 무한 쿼리에는 `_infinite`를 **추가**만 하므로 + * `getLocationVideos` infinite도 같은 부분 키에 매칭된다. + * + * 단건 조회 2종은 `refetchType: "none"` — 다음 마운트에서만 재조회한다: + * - `getPlayback`·`getVideoDetail`은 재조회가 조회수를 올린다(MSG-431·MSG-562). + * - 재생 화면에서 차단 직후 활성 재조회하면 pop 전에 404 안내가 플래시한다. + * 차단 목록(`getBlockedUsers`)은 여기 없다 — 해제는 seed(`removeBlockedUser`)로 반영한다. + */ +const invalidateAllOf = ( + queryClient: QueryClient, + [key]: [{ _id: string }], + refetchType?: "none", +): void => { + void queryClient.invalidateQueries({ + queryKey: [{ _id: key._id }], + refetchType, + }); +}; + +export const invalidateAfterBlockChange = (queryClient: QueryClient): void => { + invalidateAllOf( + queryClient, + getGridGlobalVideosQueryKey({ path: { gridId: "" } }), + ); + invalidateAllOf( + queryClient, + getLocationVideosQueryKey({ path: { occurrenceId: 0, locationId: 0 } }), + ); + invalidateAllOf( + queryClient, + getMissionVideosQueryKey({ path: { missionId: 0 } }), + ); + invalidateAllOf(queryClient, getCommentsQueryKey({ path: { videoId: 0 } })); + invalidateAllOf( + queryClient, + getPlaybackQueryKey({ path: { videoId: 0 } }), + "none", + ); + invalidateAllOf( + queryClient, + getVideoDetailQueryKey({ path: { videoId: 0 } }), + "none", + ); +}; diff --git a/apps/mobile/src/features/user-block/api/use-blocked-users-query.ts b/apps/mobile/src/features/user-block/api/use-blocked-users-query.ts new file mode 100644 index 00000000..36539a50 --- /dev/null +++ b/apps/mobile/src/features/user-block/api/use-blocked-users-query.ts @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; +import { unwrapEnvelope } from "../../../shared/api/envelope"; +import { getBlockedUsersOptions } from "../../../shared/api/query-options"; +import type { BlockedUserResponseDto } from "../../../shared/api/sdk"; + +/** + * 차단한 사용자 목록 (MSG-570 기준 13) — `GET /api/users/me/blocks`, 서버 최신순·페이지 없음. + * 반환 형태는 report-history 훅과 동형이라 화면이 같은 4상태 스위치(`resolveBlockListState`)를 탄다. + * 해제 성공은 뮤테이션이 이 캐시를 seed로 갱신한다 — 여기서 재조회하지 않는다. + */ +export interface BlockedUsersQueryResult { + items: readonly BlockedUserResponseDto[]; + isPending: boolean; + isError: boolean; + refetch: () => void; +} + +/** 참조가 매번 바뀌지 않도록 모듈 상수로 고정 */ +const NO_ITEMS: readonly BlockedUserResponseDto[] = []; + +export const useBlockedUsersQuery = (): BlockedUsersQueryResult => { + const query = useQuery({ + ...getBlockedUsersOptions(), + select: unwrapEnvelope, + }); + + return { + items: query.data ?? NO_ITEMS, + isPending: query.isPending, + isError: query.isError, + refetch: () => void query.refetch(), + }; +}; diff --git a/apps/mobile/src/features/user-block/api/use-user-block-mutations.ts b/apps/mobile/src/features/user-block/api/use-user-block-mutations.ts new file mode 100644 index 00000000..72090786 --- /dev/null +++ b/apps/mobile/src/features/user-block/api/use-user-block-mutations.ts @@ -0,0 +1,54 @@ +import { + useMutation, + useQueryClient, + type MutationKey, + type QueryClient, + type UseMutationOptions, +} from "@tanstack/react-query"; +import { guardMutate } from "../../video-actions/api/video-mutations"; +import { + blockUserMutationOptions, + unblockUserMutationOptions, + USER_BLOCK_MUTATION_KEYS, +} from "./user-block-mutations"; + +/** + * 사용자 차단·해제 훅 (MSG-570 기준 8) — 옵션 팩토리(`user-block-mutations`)에 QueryClient를 + * 물리는 얇은 층. `mutate`에 in-flight 가드(`guardMutate`, video-actions 선례)를 씌우고 + * `mutateAsync`를 감춘다 — 같은 키의 요청이 진행 중이면 재발사가 무시된다. + * 콜백은 훅 레벨 옵션 — mutate per-call 콜백은 관찰자 언마운트 시 유실된다(MSG-325). + */ + +/** 가드 래핑 공통부 — 두 훅이 같은 형태라 여기서 한 번만 (제네릭이라 입력 타입이 넓어지지 않는다) */ +const useGuardedMutation = ( + mutationKey: MutationKey, + build: ( + queryClient: QueryClient, + ) => UseMutationOptions, +) => { + const queryClient = useQueryClient(); + const mutation = useMutation(build(queryClient)); + const { mutateAsync, ...guarded } = mutation; + void mutateAsync; + return { + ...guarded, + mutate: guardMutate(queryClient, mutationKey, mutation.mutate), + }; +}; + +export const useBlockUser = (callbacks?: { + onBlocked?: () => void; + onError?: () => void; +}) => + useGuardedMutation(USER_BLOCK_MUTATION_KEYS.block, (queryClient) => + blockUserMutationOptions({ + queryClient, + onBlocked: callbacks?.onBlocked, + onError: callbacks?.onError, + }), + ); + +export const useUnblockUser = (callbacks?: { onError?: () => void }) => + useGuardedMutation(USER_BLOCK_MUTATION_KEYS.unblock, (queryClient) => + unblockUserMutationOptions({ queryClient, onError: callbacks?.onError }), + ); diff --git a/apps/mobile/src/features/user-block/api/user-block-mutations.test.ts b/apps/mobile/src/features/user-block/api/user-block-mutations.test.ts new file mode 100644 index 00000000..8c3a5fad --- /dev/null +++ b/apps/mobile/src/features/user-block/api/user-block-mutations.test.ts @@ -0,0 +1,244 @@ +import { MutationObserver, QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + envelopeResponse, + errorEnvelope, +} from "../../../test/envelope-response"; + +/** + * 템플릿 ③ 쿼리 훅(모바일 변형) — 사용자 차단·해제 mutation의 요청·성공 처리·실패 계약 + * (MSG-570 기준 4·6·7·8·14·15). RN 렌더 인프라가 없어 훅이 그대로 넘기는 옵션 객체를 + * MutationObserver로 구동한다 (video-mutations.test 관례). + */ + +const API_BASE = "https://api.test.local"; +const USER_ID = 42; +const GRID_ID = "16858_11420"; + +const loadModule = async () => { + vi.stubEnv("EXPO_PUBLIC_API_BASE_URL", API_BASE); + vi.resetModules(); + const mutations = await import("./user-block-mutations"); + const keys = await import("../../../shared/api/query-options"); + const { registerApiErrorInterceptor } = + await import("../../../shared/api/error-interceptor"); + registerApiErrorInterceptor(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return { mutations, keys, queryClient }; +}; + +interface ReceivedRequest { + method: string; + pathname: string; +} + +const stubFetch = ( + route: (request: Request) => Response | Promise, +) => { + const received: ReceivedRequest[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: Request) => { + received.push({ + method: input.method, + pathname: new URL(input.url).pathname, + }); + return route(input); + }), + ); + return received; +}; + +/** 응답을 붙잡아 두는 fetch 스텁 — in-flight 창을 열어 연타 가드를 관찰한다 */ +const stubGatedFetch = (respond: () => Response) => { + let release: (() => void) | undefined; + let markStarted: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const received = stubFetch(async () => { + markStarted?.(); + await gate; + return respond(); + }); + return { received, started, release: () => release?.() }; +}; + +const blockedEnvelope = (userIds: number[]) => ({ + developCode: 0, + message: "ok", + data: userIds.map((userId) => ({ + userId, + nickname: `user${userId}`, + profileImageUrl: null, + blockedAt: "2026-09-11T02:30:00", + })), +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("사용자 차단 mutation (기준 4·6·7·8)", () => { + it("[차단] → 작성자 userId를 경로로 POST /api/users/{userId}/block이 1회 발사된다 (기준 4)", async () => { + const { mutations, queryClient } = await loadModule(); + const received = stubFetch(() => envelopeResponse(null)); + const observer = new MutationObserver( + queryClient, + mutations.blockUserMutationOptions({ queryClient }), + ); + + await observer.mutate({ userId: USER_ID }); + + expect(received).toEqual([ + { method: "POST", pathname: `/api/users/${USER_ID}/block` }, + ]); + }); + + it("성공하면 격자 전역 영상 캐시가 무효화되고 완료 콜백이 불린다 (기준 5·6)", async () => { + const { mutations, keys, queryClient } = await loadModule(); + const gridKey = keys.getGridGlobalVideosQueryKey({ + path: { gridId: GRID_ID }, + }); + queryClient.setQueryData(gridKey, { ok: true }); + stubFetch(() => envelopeResponse(null)); + const onBlocked = vi.fn(); + const observer = new MutationObserver( + queryClient, + mutations.blockUserMutationOptions({ queryClient, onBlocked }), + ); + + await observer.mutate({ userId: USER_ID }); + + expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(true); + expect(onBlocked).toHaveBeenCalledTimes(1); + }); + + it("실패하면 어떤 쿼리도 무효화되지 않고 완료 콜백 없이 실패 콜백만 불린다 — 다이얼로그가 남는다 (기준 7)", async () => { + const { mutations, keys, queryClient } = await loadModule(); + const gridKey = keys.getGridGlobalVideosQueryKey({ + path: { gridId: GRID_ID }, + }); + queryClient.setQueryData(gridKey, { ok: true }); + stubFetch(() => errorEnvelope(9999, "차단 실패", 500)); + const onBlocked = vi.fn(); + const onError = vi.fn(); + const observer = new MutationObserver( + queryClient, + mutations.blockUserMutationOptions({ queryClient, onBlocked, onError }), + ); + + await expect(observer.mutate({ userId: USER_ID })).rejects.toThrow(); + + expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(false); + expect(onBlocked).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it("차단 요청이 진행 중인 동안 재발사가 무시된다 — 중복 POST 없음 (기준 8)", async () => { + const { mutations, queryClient } = await loadModule(); + const { received, started, release } = stubGatedFetch(() => + envelopeResponse(null), + ); + const observer = new MutationObserver( + queryClient, + mutations.blockUserMutationOptions({ queryClient }), + ); + const { guardMutate } = + await import("../../video-actions/api/video-mutations"); + const mutate = vi.fn(); + const guarded = guardMutate( + queryClient, + mutations.USER_BLOCK_MUTATION_KEYS.block, + mutate, + ); + + const inFlight = observer.mutate({ userId: USER_ID }); + await started; + guarded({ userId: USER_ID }); + + expect(mutate).not.toHaveBeenCalled(); + expect(received).toHaveLength(1); + + release(); + await inFlight; + guarded({ userId: USER_ID }); + + expect(mutate).toHaveBeenCalledTimes(1); + }); +}); + +describe("차단 해제 mutation (기준 14·15)", () => { + it("[차단 해제] → 확인 없이 DELETE /api/users/{userId}/block이 발사된다 (기준 14)", async () => { + const { mutations, queryClient } = await loadModule(); + const received = stubFetch(() => envelopeResponse(null)); + const observer = new MutationObserver( + queryClient, + mutations.unblockUserMutationOptions({ queryClient }), + ); + + await observer.mutate({ userId: USER_ID }); + + expect(received).toEqual([ + { method: "DELETE", pathname: `/api/users/${USER_ID}/block` }, + ]); + }); + + it("성공하면 그 행이 차단 목록 캐시에서 즉시 제거되고(재조회 없음) 콘텐츠 목록은 무효화된다 (기준 14)", async () => { + const { mutations, keys, queryClient } = await loadModule(); + const listKey = keys.getBlockedUsersQueryKey(); + queryClient.setQueryData(listKey, blockedEnvelope([3, USER_ID, 7])); + const gridKey = keys.getGridGlobalVideosQueryKey({ + path: { gridId: GRID_ID }, + }); + queryClient.setQueryData(gridKey, { ok: true }); + stubFetch(() => envelopeResponse(null)); + const observer = new MutationObserver( + queryClient, + mutations.unblockUserMutationOptions({ queryClient }), + ); + + await observer.mutate({ userId: USER_ID }); + + expect( + queryClient + .getQueryData<{ data: { userId: number }[] }>(listKey) + ?.data.map((u) => u.userId), + ).toEqual([3, 7]); + expect(queryClient.getQueryState(listKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(true); + }); + + it("실패하면 행이 남고 무효화 없이 실패 콜백만 불린다 (기준 15)", async () => { + const { mutations, keys, queryClient } = await loadModule(); + const listKey = keys.getBlockedUsersQueryKey(); + queryClient.setQueryData(listKey, blockedEnvelope([3, USER_ID])); + const gridKey = keys.getGridGlobalVideosQueryKey({ + path: { gridId: GRID_ID }, + }); + queryClient.setQueryData(gridKey, { ok: true }); + stubFetch(() => errorEnvelope(9999, "해제 실패", 500)); + const onError = vi.fn(); + const observer = new MutationObserver( + queryClient, + mutations.unblockUserMutationOptions({ queryClient, onError }), + ); + + await expect(observer.mutate({ userId: USER_ID })).rejects.toThrow(); + + expect( + queryClient + .getQueryData<{ data: { userId: number }[] }>(listKey) + ?.data.map((u) => u.userId), + ).toEqual([3, USER_ID]); + expect(queryClient.getQueryState(gridKey)?.isInvalidated).toBe(false); + expect(onError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/features/user-block/api/user-block-mutations.ts b/apps/mobile/src/features/user-block/api/user-block-mutations.ts new file mode 100644 index 00000000..8f26ce19 --- /dev/null +++ b/apps/mobile/src/features/user-block/api/user-block-mutations.ts @@ -0,0 +1,92 @@ +import type { + MutationKey, + QueryClient, + UseMutationOptions, +} from "@tanstack/react-query"; +import { + blockMutation, + getBlockedUsersQueryKey, + unblockMutation, +} from "../../../shared/api/query-options"; +import type { ApiResponseDtoListBlockedUserResponseDto } from "../../../shared/api/sdk"; +import { removeBlockedUser } from "../model/user-block"; +import { invalidateAfterBlockChange } from "./invalidate-blocked-content"; + +/** + * 사용자 차단·해제 mutation 옵션 (MSG-570 기준 4·6·7·14·15) — "옵션 팩토리 + 얇은 훅" + * (MSG-426 관례). 테스트는 이 객체를 `MutationObserver`로 직접 구동한다. + * 얇은 훅은 `use-user-block-mutations.ts`가 소유한다. + */ + +// 생성 팩토리는 mutationFn을 항상 채운다 — UseMutationOptions 타입만 optional이라 !로 좁힌다 +const blockFn = blockMutation().mutationFn!; +const unblockFn = unblockMutation().mutationFn!; + +/** in-flight 판정용 키 — `guardMutate`가 이 키로 진행 중 mutation을 센다 (기준 8) */ +export const USER_BLOCK_MUTATION_KEYS = { + block: ["user-block", "block"], + unblock: ["user-block", "unblock"], +} as const satisfies Record; + +export interface UserBlockInput { + userId: number; +} + +/** + * 차단 (기준 4·6·7) — `POST /api/users/{userId}/block`. 성공 시 콘텐츠 목록을 무효화하고 + * 완료 콜백을 부른다. 실패 시 아무것도 무효화하지 않아 다이얼로그가 열린 채 재시도할 수 있다. + */ +export const blockUserMutationOptions = ({ + queryClient, + onBlocked, + onError, +}: { + queryClient: QueryClient; + onBlocked?: () => void; + onError?: () => void; +}): UseMutationOptions< + Awaited>, + Error, + UserBlockInput +> => ({ + mutationKey: USER_BLOCK_MUTATION_KEYS.block, + mutationFn: (input, context) => + blockFn({ path: { userId: input.userId } }, context), + onSuccess: () => { + invalidateAfterBlockChange(queryClient); + onBlocked?.(); + }, + onError: () => onError?.(), +}); + +/** + * 해제 (기준 14·15) — `DELETE /api/users/{userId}/block`. 성공 시 차단 목록 캐시에서 그 행을 + * seed로 제거하고(재조회 없음 — 목록은 최신순 단순 배열이라 로컬 필터가 정본과 같다) + * 콘텐츠 목록을 무효화한다. 실패 시 행이 남는다. + */ +export const unblockUserMutationOptions = ({ + queryClient, + onError, +}: { + queryClient: QueryClient; + onError?: () => void; +}): UseMutationOptions< + Awaited>, + Error, + UserBlockInput +> => ({ + mutationKey: USER_BLOCK_MUTATION_KEYS.unblock, + mutationFn: (input, context) => + unblockFn({ path: { userId: input.userId } }, context), + onSuccess: (_data, { userId }) => { + queryClient.setQueryData( + getBlockedUsersQueryKey(), + (previous) => + previous === undefined + ? previous + : { ...previous, data: removeBlockedUser(previous.data, userId) }, + ); + invalidateAfterBlockChange(queryClient); + }, + onError: () => onError?.(), +}); diff --git a/apps/mobile/src/features/user-block/model/user-block.test.ts b/apps/mobile/src/features/user-block/model/user-block.test.ts new file mode 100644 index 00000000..33d4d5f9 --- /dev/null +++ b/apps/mobile/src/features/user-block/model/user-block.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type { BlockedUserResponseDto } from "../../../shared/api/sdk"; +import { + BLOCK_CONFIRM_DESCRIPTION, + blockConfirmTitle, + removeBlockedUser, + resolveBlockListState, + toBlockedUserRowView, +} from "./user-block"; + +/** + * 템플릿 ① 순수 로직 — 사용자 차단 확인 문구·차단 목록 4상태 판정·행 표시 파생·해제 seed + * (MSG-570 기준 3·13·14·16). 화면은 이 파생만 읽는 얇은 스위치다. + */ + +const blocked = ( + overrides: Partial = {}, +): BlockedUserResponseDto => ({ + userId: 42, + nickname: "서면탐험가", + profileImageUrl: null, + blockedAt: "2026-09-11T02:30:00", + ...overrides, +}); + +describe("blockConfirmTitle — 차단 확인 다이얼로그 제목 (기준 3)", () => { + it("'@닉네임 님을 차단할까요?' 형식이다 — @는 FE가 붙인다 (기준 3)", () => { + expect(blockConfirmTitle("서면탐험가")).toBe( + "@서면탐험가 님을 차단할까요?", + ); + }); + + it("본문은 영상·댓글이 사라진다는 안내와 해제 위치를 함께 알린다 (기준 3)", () => { + expect(BLOCK_CONFIRM_DESCRIPTION).toBe( + "이 사용자의 영상과 댓글이 더 이상 보이지 않아요. 프로필 > 차단한 사용자에서 해제할 수 있어요", + ); + }); +}); + +describe("resolveBlockListState — 로딩·실패·빈·목록 4상태 판정 (기준 16)", () => { + it("실패 > 로딩 > 빈 > 목록 순으로 판정한다 — 재조회 중 실패도 실패다 (기준 16)", () => { + const item = blocked(); + + expect( + resolveBlockListState({ isPending: true, isError: true, items: [item] }), + ).toBe("error"); + expect( + resolveBlockListState({ isPending: true, isError: false, items: [] }), + ).toBe("loading"); + expect( + resolveBlockListState({ isPending: false, isError: false, items: [] }), + ).toBe("empty"); + expect( + resolveBlockListState({ + isPending: false, + isError: false, + items: [item], + }), + ).toBe("list"); + }); +}); + +describe("toBlockedUserRowView — 차단 1건 → 행 표시 재료 (기준 13)", () => { + it("닉네임·이니셜(첫 글자)·아바타 URL·차단일 YYYY.MM.DD(KST)가 파생된다 (기준 13)", () => { + expect( + toBlockedUserRowView( + blocked({ + profileImageUrl: "https://cdn.test/42.jpg", + blockedAt: "2026-09-11T15:30:00", + }), + ), + ).toEqual({ + nickname: "서면탐험가", + initial: "서", + avatarUrl: "https://cdn.test/42.jpg", + blockedAt: "2026.09.12", + }); + }); + + it("프로필 이미지가 없으면 avatarUrl이 undefined라 Avatar가 이니셜 폴백을 그린다 (기준 13)", () => { + expect(toBlockedUserRowView(blocked()).avatarUrl).toBeUndefined(); + }); +}); + +describe("removeBlockedUser — 해제 성공 seed용 필터 (기준 14)", () => { + it("해제한 userId의 행만 빠지고 서버 순서는 유지된다 (기준 14)", () => { + const list = [ + blocked({ userId: 3 }), + blocked({ userId: 42 }), + blocked({ userId: 7 }), + ]; + + expect(removeBlockedUser(list, 42).map((u) => u.userId)).toEqual([3, 7]); + }); + + it("목록에 없는 userId면 그대로다 (경계)", () => { + const list = [blocked({ userId: 3 })]; + + expect(removeBlockedUser(list, 99)).toEqual(list); + }); +}); diff --git a/apps/mobile/src/features/user-block/model/user-block.ts b/apps/mobile/src/features/user-block/model/user-block.ts new file mode 100644 index 00000000..64db8e0f --- /dev/null +++ b/apps/mobile/src/features/user-block/model/user-block.ts @@ -0,0 +1,57 @@ +import { + resolveListState, + type ListState, +} from "../../../shared/api/list-state"; +import type { BlockedUserResponseDto } from "../../../shared/api/sdk"; +import { formatKstDate } from "../../../shared/format"; + +/** + * 사용자 차단 순수 모델 (MSG-570 기준 3·13·14·16) — 플랫폼 API·react·라우터 무의존. + * 확인 다이얼로그 문구, 차단 목록의 4상태 판정·행 표시 파생, 해제 성공 seed 필터를 둔다. + * 화면(`ui/`)은 이 파생만 읽는 얇은 스위치다 (report-history와 같은 3층 구조). + */ + +/** 차단 대상 — 진입점 3곳(격자 상세 행·재생 화면·이벤트 댓글)이 넘기는 최소 정보 */ +export interface BlockTarget { + userId: number; + nickname: string; +} + +/** 확인 다이얼로그 제목 (기준 3) — @는 FE가 붙인다(닉네임 원문 계약) */ +export const blockConfirmTitle = (nickname: string): string => + `@${nickname} 님을 차단할까요?`; + +export const BLOCK_CONFIRM_DESCRIPTION = + "이 사용자의 영상과 댓글이 더 이상 보이지 않아요. 프로필 > 차단한 사용자에서 해제할 수 있어요"; + +/** 목록 영역의 배타 상태 (기준 16) */ +export type BlockListState = ListState; + +/** 실패 > 로딩 > 빈 > 목록 — 판정 규칙은 `shared/api/list-state`(report-history와 공유) */ +export const resolveBlockListState = resolveListState; + +/** 행 1개가 그리는 재료 (기준 13) */ +export interface BlockedUserRowView { + nickname: string; + /** 아바타 이니셜 폴백 — 닉네임 첫 글자 */ + initial: string; + /** 없으면 undefined — `Avatar`가 이니셜 폴백을 그린다 */ + avatarUrl: string | undefined; + /** 차단일 "YYYY.MM.DD"(KST) — 서버 blockedAt은 타임존 마커 없는 UTC */ + blockedAt: string; +} + +export const toBlockedUserRowView = ( + dto: BlockedUserResponseDto, +): BlockedUserRowView => ({ + nickname: dto.nickname, + initial: dto.nickname.slice(0, 1), + avatarUrl: dto.profileImageUrl ?? undefined, + blockedAt: formatKstDate(dto.blockedAt), +}); + +/** 해제 성공 seed (기준 14) — 서버 순서를 유지한 채 그 행만 뺀다 */ +export const removeBlockedUser = ( + list: readonly BlockedUserResponseDto[], + userId: number, +): BlockedUserResponseDto[] => list.filter((user) => user.userId !== userId); diff --git a/apps/mobile/src/features/user-block/ui/block-user-dialog.tsx b/apps/mobile/src/features/user-block/ui/block-user-dialog.tsx new file mode 100644 index 00000000..1f803df2 --- /dev/null +++ b/apps/mobile/src/features/user-block/ui/block-user-dialog.tsx @@ -0,0 +1,65 @@ +import { ModalCard, Toast } from "@fillmap/ui-native"; +import { useAutoDismissToast } from "../../video-actions/model/use-auto-dismiss-toast"; +import { useBlockUser } from "../api/use-user-block-mutations"; +import { + BLOCK_CONFIRM_DESCRIPTION, + blockConfirmTitle, + type BlockTarget, +} from "../model/user-block"; + +interface BlockUserDialogProps { + /** 차단 대상 — null이면 닫힘 */ + target: BlockTarget | null; + /** 취소·딤 탭·Android back — 요청 없이 닫기 (기준 3) */ + onClose: () => void; + /** 차단 성공 — 호출부가 닫고 토스트·후속(pop 등)을 처리한다 (기준 5) */ + onBlocked: () => void; +} + +/** + * 사용자 차단 확인 다이얼로그 (MSG-570 기준 3·4·5·7·8) — 진입점 3곳(격자 상세·재생 화면· + * 이벤트 댓글)이 **이 컴포넌트 하나**를 쓴다. 뮤테이션·진행 중·실패 문구를 여기가 소유해 + * 진입점이 셋이어도 동작은 하나다. + * 앱 확인 관례(영상 삭제·로그아웃·탈퇴)대로 `ModalCard` 중앙 카드(A1). 실패 안내는 카드 안 + * `Toast` — RN Modal 경계상 호스트 트리 토스트는 이 창 뒤에 가린다(삭제 다이얼로그 동형). + */ +export const BlockUserDialog = ({ + target, + onClose, + onBlocked, +}: BlockUserDialogProps) => { + const [failure, setFailure] = useAutoDismissToast(); + + // 닫힘 경로 전부에서 실패 안내를 비운다 — 다음 대상의 다이얼로그에 지난 실패가 남지 않게 + const close = () => { + setFailure(null); + onClose(); + }; + const block = useBlockUser({ + onBlocked: () => { + setFailure(null); + onBlocked(); + }, + onError: () => setFailure("차단하지 못했어요. 잠시 후 다시 시도해 주세요"), + }); + + return ( + { + // 연타 방어는 `mutate`의 in-flight 가드가 맡는다 (guardMutate) + if (target !== null) block.mutate({ userId: target.userId }); + }} + > + {failure !== null && } + + ); +}; diff --git a/apps/mobile/src/features/user-block/ui/blocked-user-row.tsx b/apps/mobile/src/features/user-block/ui/blocked-user-row.tsx new file mode 100644 index 00000000..326f5f65 --- /dev/null +++ b/apps/mobile/src/features/user-block/ui/blocked-user-row.tsx @@ -0,0 +1,51 @@ +import { Text, View } from "react-native"; +import { Avatar, Button } from "@fillmap/ui-native"; +import type { BlockedUserResponseDto } from "../../../shared/api/sdk"; +import { toBlockedUserRowView } from "../model/user-block"; + +interface BlockedUserRowProps { + item: BlockedUserResponseDto; + /** 이 행의 해제 요청이 진행 중 — 버튼 비활성 (기준 15) */ + unblocking: boolean; + onUnblock: () => void; +} + +/** + * 차단한 사용자 1행 (MSG-570 기준 13) — 아바타(없으면 닉네임 첫 글자) · 닉네임 · 차단일 · + * [차단 해제]. 표시 재료는 `toBlockedUserRowView`가 만든다 — 이 행은 배치만 한다. + * 카드형 행은 신고 관리 행 관례(Figma 시안 없음). + */ +export const BlockedUserRow = ({ + item, + unblocking, + onUnblock, +}: BlockedUserRowProps) => { + const view = toBlockedUserRowView(item); + + return ( + + + + + {view.nickname} + + + {view.blockedAt} + + +