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
6 changes: 6 additions & 0 deletions apps/mobile/src/app/profile/blocks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { BlockedUsersScreen } from "../../features/user-block/ui/blocked-users-screen";

/** 차단한 사용자 라우트 (MSG-570 기준 12) — 프로필·설정에서 push */
export default function ProfileBlocks() {
return <BlockedUsersScreen />;
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/auth/model/app-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const PROTECTED_ROUTES = [
"dex/history",
"grid/[cellId]",
"profile",
"profile/blocks",
"profile/consent",
"profile/edit",
"profile/reports",
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/event/api/event-video-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 20 additions & 5 deletions apps/mobile/src/features/event/api/use-event-comments-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ export interface EventCommentsPagesResult {
/** 다음 페이지 이어받기 — 진행 중이거나 더 없으면 아무것도 하지 않는다 */
loadMore: () => void;
isLoadingMore: boolean;
/** 이어받은 페이지를 비운다 (MSG-570 기준 11) — 댓글 작성자 차단 후 첫 페이지 seed와 함께 */
reset: () => void;
}

interface ExtraPagesState {
videoId: number;
/** `reset()` 세대 — 리셋 전에 띄운 "더 보기" 응답이 리셋 뒤에 붙지 않게 (codex 리뷰 P2) */
generation: number;
pages: EventVideoCommentPageResponseDto[];
}

Expand All @@ -55,12 +59,16 @@ export const useEventCommentsPages = (
onLoadError?: (error: unknown) => void,
): EventCommentsPagesResult => {
const queryClient = useQueryClient();
const [extra, setExtra] = useState<ExtraPagesState>({ videoId, pages: [] });
const [extra, setExtra] = useState<ExtraPagesState>({
videoId,
generation: 0,
pages: [],
});
const [isLoadingMore, setIsLoadingMore] = useState(false);

// 영상 교체 시 축적 리셋 — 렌더 중 상태 조정 (React 공식 "adjusting state" 패턴)
if (extra.videoId !== videoId) {
setExtra({ videoId, pages: [] });
setExtra({ videoId, generation: 0, pages: [] });
}
const pages = extra.videoId === videoId ? extra.pages : [];

Expand All @@ -69,13 +77,14 @@ export const useEventCommentsPages = (
const loadMore = () => {
if (cursor === null || isLoadingMore) return;
setIsLoadingMore(true);
const generation = extra.generation;
void (async () => {
try {
const page = await fetchCommentsPage(queryClient, videoId, cursor);
// 요청 중 영상이 교체됐으면 폐기 — 새 영상 목록에 이전 페이지가 섞이지 않게
// 요청 중 영상이 교체됐거나 리셋(차단)됐으면 폐기 — 이전 페이지가 새 목록에 섞이지 않게
setExtra((prev) =>
prev.videoId === videoId
? { videoId, pages: [...prev.pages, page] }
prev.videoId === videoId && prev.generation === generation
? { ...prev, pages: [...prev.pages, page] }
: prev,
);
} catch (error) {
Expand All @@ -91,5 +100,11 @@ export const useEventCommentsPages = (
hasNext: cursor !== null,
loadMore,
isLoadingMore,
reset: () =>
setExtra((prev) => ({
videoId,
generation: prev.generation + 1,
pages: [],
})),
};
};
59 changes: 58 additions & 1 deletion apps/mobile/src/features/event/api/use-event-video-sheet.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
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 {
canSubmitComment,
eventInteractionErrorMessage,
trimmedCommentContent,
} from "../model/event-video-view";
import { seedDetail } from "./event-video-mutations";
import {
useEventCommentsPages,
type EventCommentsPagesResult,
Expand Down Expand Up @@ -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: (blockedUserId: number) => void;
/**
* `‹`·`✕` — `use-event-home`의 `handlers.back/close`와 같은 모듈 액션. 시트 스위치의
* 접촉면 예산(≤8줄) 때문에 prop 주입 대신 여기서 묶는다 (D11·D13)
Expand Down Expand Up @@ -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<BlockTarget | null>(null);
const [blockTarget, setBlockTarget] = useState<BlockTarget | null>(null);
const commentBlockTarget = (
comment: EventVideoCommentResponseDto,
): BlockTarget | null =>
comment.authorNickname === me?.nickname
? null
: { userId: comment.authorId, nickname: comment.authorNickname };
// 제출한 userId로 처리 — 요청 중 다이얼로그를 닫거나 다른 작성자를 고르면 `blockTarget`은
// 이미 null·다른 값이다 (codex 리뷰 P2, createComment의 videoId 대조와 같은 레이스 차단)
const onBlocked = (blockedUserId: number) => {
setBlockTarget(null);
// 상세 invalidate 금지(조회수 부작용) — seed + 페이지 리셋으로만 목록에서 뺀다 (A5)
seedDetail(queryClient, videoId, (previous) =>
removeCommentsByAuthor(previous, blockedUserId),
);
comments.reset();
setToast("차단했어요");
};

const pressHelpful = () => {
if (detail === null || detail.interactionLocked || toggleHelpful.isPending)
return;
Expand All @@ -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,
};
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/src/features/event/model/event-video-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import {
appendComment,
eventVideoInteraction,
removeCommentsByAuthor,
seedHelpful,
} from "./event-video-cache";

Expand Down Expand Up @@ -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({
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/features/event/model/event-video-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const video = (
createdAt: "2026-09-02T11:58:00+09:00",
helpfulCount: 1,
commentCount: 2,
uploaderId: 7,
...over,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
24 changes: 21 additions & 3 deletions apps/mobile/src/features/event/ui/event-video-comment-row.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
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";

/**
* 댓글 행 (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) => (
<View className="flex-row gap-xs">
<Pressable
// 내 댓글은 핸들러가 없어 종전 View와 같게 낭독된다 — disabled로 잠그면 "사용 안 함"이 읽힌다
accessible={onLongPress !== undefined}
onLongPress={onLongPress}
accessibilityActions={
onLongPress === undefined
? undefined
: [{ name: "longpress", label: "사용자 차단" }]
}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === "longpress") onLongPress?.();
}}
className="flex-row gap-xs"
>
<Avatar size="sm" fallback={comment.authorNickname.slice(0, 1)} />
<View className="flex-1 gap-xxs">
<View className="flex-row items-center justify-between gap-xs">
Expand All @@ -30,5 +48,5 @@ export const EventVideoCommentRow = ({
</View>
<Text className="text-fm-body text-foreground">{comment.content}</Text>
</View>
</View>
</Pressable>
);
Loading
Loading