Skip to content

feat: 댓글/대댓글 CRUD + 큐레이팅 화면 + 채팅방 오디오 오류 배너 #4#36

Merged
Roy-wonji merged 42 commits into
developfrom
feature/Comments
Jun 4, 2026
Merged

feat: 댓글/대댓글 CRUD + 큐레이팅 화면 + 채팅방 오디오 오류 배너 #4#36
Roy-wonji merged 42 commits into
developfrom
feature/Comments

Conversation

@Roy-wonji

Copy link
Copy Markdown
Contributor

개요

댓글(관점) 기능 전반 + 큐레이팅 화면 + 채팅방 오디오 오류 처리. picke.pen 디자인 기준으로 구현했습니다.

주요 변경

댓글 / 대댓글

  • 댓글·대댓글 수정/삭제/신고 + 좋아요 (Perspective CRUD·report·like API 연결)
  • 대댓글 수정 PUT → PATCH 메서드 버그 수정
  • "…" 메뉴 enum(MenuAction/commentRow) 통합, 터치 영역 확대
  • 필터 탭 진영(optionId)으로 관점 등록
  • 등록/수정/정렬·갱신 시 스켈레톤 노출
  • 내 댓글 "나" 표시

큐레이팅 화면 (신규)

  • GET /battles/{id}/recommendations/interesting 흥미 기반 추천 배틀 API 전 계층 연결
  • CommentView 의 > 진입, 추천 배틀 카드 리스트 (pen 동일)
  • X 버튼 → 앱 루트 복귀, 로딩 스켈레톤/빈 상태

채팅방 오디오 오류

  • 오디오 로딩 실패 감지(AVPlayerItem.isPlayable) → 상단 floating 오류 배너 (FloatingErrorView, pen 채팅방_오류)

디자인 시스템

  • 관점 삭제 팝업 finalVote UI 통일, 신고 팝업 하단 정렬
  • errorStrong 토큰 추가, hex 직접 사용 → 토큰(neutral200/secondary200)으로 교체

기타

  • Hifi 모듈 신규 생성 + 의존성 등록

Refs

Refs #4

Roy-wonji added 30 commits May 22, 2026 00:48
- BattleAPI / BattleService.postVote(battleId:, body:) 추가
- BattleInterface.submitPostVote + Default/Repository 구현 (PreVoteResult/PreVoteRequest 재사용)
- PreVoteFeature.VoteMode (.pre / .post) 신설
  - State.voteMode + State.primaryButtonTitle 으로 CTA 텍스트 분기
  - primaryButtonTapped 가 voteMode 별로 submitPreVote / submitPostVote 디스패치
  - AsyncAction.submitPostVote + InnerAction.postVoteResponse + CancelID.submitPostVote
  - 성공/실패 모두 delegate.voteSubmitted 로 동일하게 알림
- ChatRoomFeature.DelegateAction.requestFinalVote(battleId) 추가
  - customAlert.confirmTapped 시 audioPlayer.pause + delegate.requestFinalVote 발행
- ChatCoordinator: chatRoom.requestFinalVote 수신 시 .preVote(.init(battleId:, voteMode: .post)) push
- 모든 switch 분기에 explicit return 적용
- 일부 battle 응답에서 options[].label 이 누락되어 BattleOptionDTO 디코딩 실패하던 문제 수정
- BattleOptionDTO.label 을 Optional 로 변경
- 매퍼에서 nil 일 때 빈 문자열 fallback (Entity 의 label 은 표시용으로 직접 사용되지 않으므로 안전)
- 일부 시나리오 응답에서 philosophers[].label 이 누락되어 ScenarioPhilosopherDTO
  디코딩 실패하던 문제 수정
- ScenarioPhilosopherDTO.label 을 Optional 로 변경
- 매퍼에서 인덱스 기반 폴백(A/B/C/D…) 적용해 ChatRoomFeature 의 label 매칭이
  계속 유효하도록 보장
- ChatRoomView 의 layout 상수(bubbleMaxWidth / avatarSize / avatarImageWidth / avatarImageHeight) 를
  private enum Metric 으로 묶음. 호출처 모두 Self → Metric 으로 정리
- ChatRoomFeature.View 에 onDisappear 추가
  - audioObserver(.audioObserver) cancel + audioPlayer.pause + isPlaying = false
  - swipe back / programmatic dismiss 등 backButton 외 경로로 화면을 나가도 음원이 멈춤
- ChatRoomView 에 .onDisappear { send(.onDisappear) } 연결
- Domain/Entity/Home/Comment.swift 신설
  - Comment / CommentAuthor / CommentSortType / CommentTab 모델
- Chat 모듈에 Comment Feature/View 디렉터리 추가 (mock 단계)
- ChatCoordinator.ChatScreen.comment(CommentFeature) 케이스 추가
  - preVote.voteSubmitted 시 voteMode 분기:
    - .pre  → chatRoom push
    - .post → comment push (최종 투표 후 댓글 화면 진입)
  - comment.delegate.dismiss 시 backAction
- ChatCoordinatorView 에 comment 케이스 렌더
- PreVoteFeature.DelegateAction.voteSubmitted 시그니처에 voteMode 추가
- ChatRoomFeature
  - hasFinishedListening 을 UserDefaults 로 영속화해 재진입 시 시킹 즉시 허용
- CustomConfirmationPopupView 정비 (디자인 토큰 / 패딩 정렬)
- Entity/Home/BattleVoteStats + BattleVoteStatsOption 신설
- Data Model: BattleVoteStatsDataDTO + BattleVoteStatsOptionDTO + 매퍼
  (updatedAt 은 ISO8601 fractional seconds 파서)
- BattleAPI / BattleService.voteStats(battleId:) (GET, no body)
- BattleInterface.fetchVoteStats + Default/Repository 구현
- CommentFeature
  - AsyncAction.fetchVoteStats + InnerAction.voteStatsResponse(Result)
  - View.onAppear 에서 fetch 트리거 (isLoadingStats 가드)
  - 성공 시 응답 옵션 0/1 을 VoteSummary.optionA/B 에 매핑, ratio·label·stance 반영
  - 실패 시 fallback (기존 voteSummary) 유지 + 로그
- CommentView.onAppear 에서 send(.onAppear) 호출
- Entity/Error/BattleError 신설 (Attendance_iOS 의 AttendanceError 패턴)
  - networkError / decodingError / noData / unauthorized / backendError /
    serverError / unknown + LocalizedError 텍스트 + BattleError.from(_:) 컨버터
- BattleRepositoryImpl: 모든 throw AuthError.backendError → throw BattleError.backendError
- PreVoteFeature / ChatRoomFeature / CommentFeature
  - Result<*, AuthError> → Result<*, BattleError>
  - mapError(AuthError.from) → mapError(BattleError.from)
- BattleVoteStatsOptionDTO
  - title 을 Optional 로 (응답에서 누락되는 케이스 디코딩 실패 해결)
  - imageUrl 필드 추가
- BattleVoteStatsOption Entity 에 imageUrl 추가
- 매퍼: ratio 가 1 보다 크면(서버가 퍼센트로 내려준 경우) 100 으로 나눠 비율로 정규화


- Entity/Home/BattlePerspective + Page + User + Option + BattlePerspectiveSort 신설
- Data Model: BattlePerspectivePageDataDTO / 매퍼 (ISO8601 fractional 파서, null fallback)
- BattleAPI / BattleService.perspectives(battleId:, cursor:, size:, optionLabel:, sort:) (GET + query)
- BattleInterface.fetchPerspectives + Default/Repository 구현
- CommentFeature
  - State.isLoadingComments / nextCursor / hasNext / comments
  - AsyncAction.fetchPerspectives(reset:) + InnerAction.perspectivesResponse(Result, reset:)
  - onAppear 에서 voteStats + perspectives 병렬 호출
  - filterTapped / sortTapped 시 reset 페치 (서버에 optionLabel / sort 쿼리)
  - 응답 BattlePerspective 를 CommentItem 으로 매핑 (deterministic UUID, relativeTime)
  - CommentFilter.queryLabel / CommentSort.perspectiveSort computed 로 enum → 쿼리값 매핑
- PieckeDomain.comment 추가 (api/v1/comments/)
- Entity: CommentLikeResult / CommentError 신설
- Data: CommentLikeDataDTO + 매퍼
- CommentAPI / CommentService.like(POST) · unlike(DELETE) 추가 (헤더 only)
- DomainInterface: CommentInterface (likeComment / unlikeComment) + DependencyKey + Default 구현
- Repository: CommentRepositoryImpl (MoyaProvider<CommentService>.authorized)
- App DI: CommentRepositoryImpl as CommentInterface 등록
- CommentFeature
  - State.CommentItem.perspectiveId 필드 추가, BattlePerspective 매핑 시 ID 보존
  - View.likeTapped 시 optimistic update + AsyncAction.toggleLike 디스패치 (현재 isLiked 기준 POST/DELETE 분기)
  - InnerAction.likeResponse 로 서버 응답(likeCount/isLiked) 반영, 실패 시 로그
  - @dependency(\.commentRepository) 주입
- PieckeDomain.perspective 추가 (api/v1/perspectives/)
- Entity
  - PerspectiveComment / PerspectiveCommentPage / PerspectiveCommentUser
  - PerspectiveCommentMutationResult (create/update 응답)
  - CommentReplyItem 에 commentId 필드 + PerspectiveComment 매핑 init 추가
- Data Model: Perspective DTO/매퍼 (cursor 페이지 + create/update 응답 + 부모 perspective detail 재사용)
- PerspectiveAPI / PerspectiveService
  - detail (GET) / listLabeledComments (GET + cursor·size) / createComment (POST) /
    updateComment (PUT) / deleteComment (DELETE)
  - PerspectiveCommentBody { content }
- DomainInterface: PerspectiveInterface + Default + DependencyKey
- Repository: PerspectiveRepositoryImpl (MoyaProvider<PerspectiveService>.authorized) + EmptyDTO
- App DI: PerspectiveRepositoryImpl as PerspectiveInterface 등록
- CommentReplyFeature
  - State: perspectiveId / parentComment / replies / nextCursor / hasNext / editingCommentId / 로딩 플래그
  - AsyncAction: fetchParent / fetchReplies(reset:) / createReply / updateReply / deleteReply /
    toggleParentLike / toggleReplyLike
  - InnerAction 으로 응답 단일화, 좋아요는 /comments/{id}/likes 재사용
  - onAppear 시 부모 + 답글 페이지 병렬 페치, sendTapped 시 createReply/updateReply 분기
…렬 토큰 정리 #4

- CommentFeature.State.perspectiveId(Int?) / isSubmitting 추가
  - isSendEnabled 가 perspectiveId 존재 + 비제출 중일 때만 활성
- AsyncAction.createComment(perspectiveId:, content:) + InnerAction.createCommentResponse 추가
  - sendTapped 시 perspectiveRepository.createComment(POST /perspectives/{pid}/comments) 호출
  - 성공 시 fetchPerspectives(reset:true) 로 리스트 재로딩
  - @dependency(\.perspectiveRepository) 주입
- BattleService.createPerspective + CreatePerspectiveRequest 신설 (battle 단위 댓글 작성용도 함께 준비 — 백엔드 endpoint 확정 시 활용)
- BattleInterface.createPerspective + Default/Repository 구현 (BattlePerspective 반환)
- CommentView 정렬 버튼 색상을 디자인 토큰(.primary500 / .primary50) 으로 정리, hardcoded hex 제거
- CommentView 필터 탭 라벨을 voteSummary.optionA/B.title 로 동적 매핑 (.pen `전체/<옵션 title>/<옵션 title>` 매칭)
- 필터 탭 하단 underline 추가 (선택 primary500 2.5pt, 비선택 neutral200 1pt)
- CommentItem.authorImageURL / optionLabel 필드 추가
  - BattlePerspective → CommentItem 매핑 시 user.characterImageUrl + option.title 전달
- CommentView 댓글 카드
  - 아바타: 첫 글자 텍스트 → KFImage(authorImageURL) + Circle stroke beige600
    (URL 없으면 기존 fallback 사용)
  - 옵션 라벨 뱃지: "A 변기는 변기다" → "변기는 변기다" 만 (.pen Z3egi 매칭)
  - 시간 폰트 10pt → 12pt Medium (.pen `nI8bv` 매칭)
- Kingfisher import 추가 (Chat 모듈)
- CustomConfirmationPopupView.reportContent .pen byHAR 매칭
  - 헤더 "신고사유" 16pt SemiBold .primary800 (#653226)
  - 사유 5개 라디오 grid (2-column)
    - 좌: 영리목적/홍보성 · 음란성/선정성 · 기타
    - 우: 같은 내용 반복 게시 · 욕설/인신공격
  - Ellipse 20x20 stroke .gray200 (#b0afae) 3pt + 선택 시 inner Circle .primary500
  - 버튼 horizontal: 신고하기 (.secondary50 fill / .primary800 text) /
                    뒤로가기 (.primary500 fill / .secondary50 text)
  - frame 343 cornerRadius 6 .beige500 (#f5f1ea) + border .primary500 1.5pt
  - ReportReason enum (commercial/repeat_/explicit/insult/other) 추가
- CommentView.sortButton 간격 0 → 8pt (.pen sort frame gap 매칭)
- byHAR 디자인 매칭: 전체 frame width 343 / height 236
  - padding top 20 + 헤더 + gap 16 + 사유 grid (height 116) + gap 16 + 버튼
- 사유 grid: 내부 padding vertical 12 + .frame(height: 116) 명시
- reportContent 를 @ViewBuilder 서브뷰로 분리
  - reportHeader / reasonGrid / reasonColumn / reasonRow / reasonRadio
  - reportButtons / reportSubmitButton / reportCancelButton
…틀 타이틀 동적 #4

- AGENTS.md ViewBuilder 규칙(95~196) 준수
  - 모든 sub-view 를 @ViewBuilder private func 으로 분리
  - navigationBar/summarySection/changeBadge/voteSide/voteProgress/avatarLabel
  - filterSection/filterTabs/sortRow/filterButton/sortButton
  - commentList/commentCard/commentBody/commentHeader/commentAuthorBlock
  - reportButton/optionBadge/commentActions/moreButton/replyCountButton
  - likeButton/actionLabel/avatar
  - inputBar/inputTextBox/sendButton
- CommentFeature: fetchBattle 추가
  - AsyncAction.fetchBattle / InnerAction.battleResponse / CancelID.fetchBattle
  - onAppear merge 에 fetchBattle 추가
  - 응답 성공 시 state.title = detail.battleInfo.title
- State.init default title "" + voteSummary .mock (서버 응답 도착하면 덮어씀)
- CommentItem 을 Entity/Sources/Home/Comment.swift 로 이동
  - BattlePerspective→CommentItem 매핑 init / deterministicUUID / relativeTimeString 포함
  - 사용처 없는 mocks 정적 배열 제거
- VoteSummary / VoteOptionSummary / CommentFilter / CommentSort 신규 파일
  Entity/Sources/Home/VoteSummary.swift 로 이동
- CommentReplyFeature: replies 기본값 mock fallback → 빈 배열
- CommentReplyView Preview 제거 (mockdata 의존 끊기)
- AGENTS.md ViewBuilder 규칙 강화: 모든 sub-view 는 @ViewBuilder + private func
  단일 뷰여도 var 형태 금지 — 호출부 일관성 확보
- finalVoteContent HStack spacing 10 → 0
  두 버튼 (다시 들어볼래요 / 최종투표하기) 이 붙어있도록 변경
- inline default + 외부 주입 인자만 init 파라미터로 노출
- CommentFeature.State.init(battleId:) — title/voteSummary/comments 등은 inline default
- PreVoteFeature.State.init(battleId:, voteMode:) — battle 파라미터 제거 (internal state)
- CommentReplyFeature.State.init(perspectiveId:, parentComment:) — replies 인자 제거
- PreVoteView Preview: .init() 만 호출 (.mock 의존 제거)
- AGENTS.md: 파라미터 ≥ 2 시 멀티 라인 규칙 추가
…re UseCase 패턴 도입 #4

API
- GET /api/v1/battles/{battleId}/perspectives/me — 본인 perspective 조회
- DELETE /api/v1/perspectives/{perspectiveId} — perspective 삭제
- BattleAPI/PerspectiveAPI / Service / RepositoryImpl 모두 연결
- PerspectivesQueryRequest 별도 파일로 분리 (Encodable)

UseCase 도입 (Attendance_iOS 패턴)
- BattleUseCaseImpl / HomeUseCaseImpl / CommentUseCaseImpl / PerspectiveUseCaseImpl
  기존 Interface 그대로 채택 + @dependency 로 Repository 주입
- 각 Impl 에 DependencyKey 채택 (live / test / preview 모두 명시)
- DependencyValues 키: battleUseCase / homeUseCase / commentUseCase / perspectiveUseCase
- 5개 Feature 의 @dependency 를 Repository → UseCase 로 전부 교체

PreVoteFeature
- voteMode == .pre 일 때만 fetchMyPerspective 호출
- 응답이 있으면 customAlert = .alreadyWatched() 표시
- confirm: deleteMyPerspective(perspectiveId:) 호출 → state.myPerspective = nil
- cancel: dismiss
- ifLet(\.$customAlert, action: \.scope.customAlert) + CustomConfirmAlert

DesignSystem
- CustomAlertStyle.alreadyWatched 추가
- CustomAlertState.alreadyWatched() 팩토리 (.pen nah54 매칭)
- CustomConfirmationPopupView.alreadyWatchedContent 신규
  헤더 / 본문 / 취소·다시 버튼 (.pen 색상 토큰 매칭)

PreVoteView
- .customAlert($store.scope(state:\.customAlert, action:\.scope.customAlert)) modifier 추가

규칙 / 포맷
- AGENTS.md: 🧩 UseCase 강제 / 📦 Feature 모델 Entity 이동 / 📏 함수 시그니처 멀티라인 규칙 추가
- .swiftformat 에 --disable redundantReturn 추가 — switch case 의 return 자동 제거 방지
AGENTS.md 의 `📏 함수 시그니처` 규칙(파라미터 ≥ 2 → 멀티 라인) 적용.
자동 일괄 변환이라 30+ 파일 단일 커밋 (커밋 단위 규칙 예외).
- 진입 시 perspectiveId 가 nil 이라 sendTapped 가 항상 막혀 댓글 작성 불가능했음
- CommentFeature.onAppear 의 merge 에 fetchMyPerspective 추가
- AsyncAction.fetchMyPerspective / InnerAction.myPerspectiveResponse / CancelID.fetchMyPerspective 신설
- 응답 성공 시 state.perspectiveId = perspective?.perspectiveId
  - 본인이 작성한 perspective 가 없으면 nil 유지 → 댓글 작성 비활성 (의도된 동작)
- Entity/Error/PerspectiveError.swift 신규
  - LocalizedError + Equatable + .from(_:) 컨버터
- PerspectiveRepositoryImpl
  - fetchPerspective / deletePerspective 의 throw 를 PerspectiveError 로 교체
  - 댓글 CRUD (fetchLabeled / create / update / deleteComment) 는 CommentError 유지
- PreVoteFeature.deleteMyPerspectiveResponse / mapError 를 PerspectiveError 로
- CommentReplyFeature.parentResponse / fetchParent mapError 를 PerspectiveError 로
Decode the createPerspective response as the lightweight creation payload returned by the server instead of requiring a full BattlePerspective object.

Rejected: Make BattlePerspectiveDTO fields optional | that would weaken list/detail decoding and hide contract mismatches. #4
After the create endpoint confirms success, fetch my perspective to preserve the BattleInterface createPerspective return contract without decoding the create response as a full resource.

Rejected: Change createPerspective to return Void | callers and interface semantics expect the created perspective value. #4
Add perspective mutation and like endpoints through Service, Repository, DomainInterface, and UseCase while moving battle perspective request/query shapes into explicit service models.

Rejected: Keep default perspective repository inline in PerspectiveInterface | the expanded interface made the file noisy and harder to maintain. #4
Connect comment and reply screens to perspective creation, edit, delete, filtering, liking, menu sheets, skeleton states, and reply navigation using the expanded domain surface.

Rejected: Continue using comment like endpoints for parent perspective cards | it updates the wrong backend resource. #4
Move share sheet models out of PreVoteFeature and build richer share payloads from battle text, tags, options, URL, and a rendered card snapshot.

Rejected: Keep ShareItem nested in PreVoteFeature | it violates the reducer-only feature file rule and bloats the reducer surface. #4
Let users deselect or change the selected quiz choice instead of locking the first tapped answer.

Rejected: Keep buttons disabled after first tap | it prevents correcting accidental taps before any commit action. #4
Add project guidance that keeps TCA feature files limited to reducer components and moves presentation helper models into module Model files.

Rejected: Leave model extraction as an implicit convention | future reducers could reintroduce nested presentation types. #4
Roy-wonji added 11 commits June 3, 2026 23:25
Show a compact 나 badge next to the author name for comments and replies backed by isMine state.

Rejected: Replace the author name with 나 | preserving the nickname keeps the row identity clear while adding ownership context. #4
The pre-vote hero image needs to fade into the beige content area so the text-image boundary does not read as a hard middle line.

Rejected: Move content lower below the image | it changes the composition and can leave the boundary visible. #4
PreVote needs a fixed presentation without user scrolling, and its screen-specific dimensions should live outside the view body for easier adjustment.

Rejected: Keep constants as private static values in PreVoteView | it keeps the view noisy and ignores the requested file split. #4
Comment and reply avatars should crop consistently inside circular frames so remote images do not appear offset or underfilled.

Rejected: Patch only CommentView | CommentReplyView renders the same avatar pattern and would keep the inconsistency. #4
@Roy-wonji Roy-wonji added ✨ 기능추가 새로운 기능 추가 🎨 디자인 UI 디자인 작업 🐛 버그 버그 수정 labels Jun 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ad33c9588

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +328 to +329
if perspective != nil {
state.customAlert = .alreadyWatched()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not delete an authored perspective to restart voting

When a returning user has previously written a perspective, fetchMyPerspective returns that authored comment and this branch presents the “watch again” alert; confirming it dispatches deleteMyPerspective, which calls DELETE /perspectives/{id}. Reopening a battle and choosing to watch again therefore permanently deletes the user's published perspective, even though a perspective is comment content rather than voting-progress state. Use an actual participation/vote-status signal instead of BattlePerspective and never delete user content as part of restarting playback.

Useful? React with 👍 / 👎.

Comment on lines +365 to +368
// 최종투표는 1회만 가능 — 이미 투표한 경우 서버가 500.
// 재투표가 불가하므로 결과(댓글) 화면으로 이동한다.
Log.error("[PreVoteFeature] submitPostVote failed: \(error.localizedDescription)")
return .send(.delegate(.alreadyFinalVoted(battleId: state.battleId)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve post-vote failures instead of assuming a prior vote

Every post-vote failure—including offline requests, authentication failures, timeouts, and unrelated server errors—is treated as if the user already completed the final vote and navigates to comments. In those scenarios the selected vote was never recorded, but the UI proceeds as though it succeeded and offers no retry. Only the specific backend response indicating an existing final vote should dispatch alreadyFinalVoted; other errors should remain on this screen.

Useful? React with 👍 / 👎.

Comment on lines +389 to +394
try await repository.fetchPerspectives(
battleId: battleId,
cursor: cursor,
size: 20,
optionId: optionId,
sort: sort

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 Add a path to fetch subsequent comment pages

The perspectives request is capped at 20 items, but every repo call site dispatches fetchPerspectives(reset: true) and there is no scroll/load-more action that dispatches reset: false. Although nextCursor and hasNext are saved, battles with more than 20 perspectives permanently hide every later comment from users. Trigger a non-reset fetch when the final visible item appears or otherwise expose pagination.

Useful? React with 👍 / 👎.

Comment on lines +467 to +468
try? await repository.deletePerspective(perspectiveId: perspectiveId)
await send(.delegate(.dismiss))

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 Dismiss the reply screen only after deletion succeeds

When deleting the parent perspective fails due to connectivity, authorization, or a backend error, try? suppresses the failure and the delegate dismissal still runs. The screen closes as if deletion succeeded even though the perspective remains on the server, misleading the user and preventing any retry or error handling. Wrap the operation in a Result response and dismiss only on success.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

{
"summary": "전반적으로 매우 훌륭한 변경 사항입니다. 새로운 기능(댓글/대댓글 CRUD, 큐레이팅, 채팅방 오디오 오류 배너)이 잘 구현되었으며, Clean Architecture, TCA, SwiftUI, Swift 코드 품질 가이드라인을 충실히 따르고 있습니다. 특히, 각 레이어의 책임 분리, 의존성 관리, 오류 처리, 그리고 UI/UX 개선을 위한 디테일한 접근 방식(스켈레톤, 에러 배너, 공유 스냅샷)이 인상 깊습니다. 전반적인 코드 품질과 아키텍처 일관성 유지에 많은 노력을 기울인 것으로 보입니다.",
"comments": [
{
"path": "Projects/App/Sources/Reducer/AppReducer.swift",
"line": 210,
"code_snippet": " private func isValidAction(",
"body": "🔵 [P4] Readability\n\n함수 매개변수 줄바꿈 스타일이 일관되게 적용되어 가독성이 향상되었습니다. 프로젝트의 스타일 가이드라인을 잘 따르고 있습니다."
},
{
"path": "Projects/Data/API/Sources/Battle/BattleAPI.swift",
"line": 21,
"code_snippet": " return "\(battleId)"",
"body": "🔵 [P4] Readability\n\n이전 코드에서는 return이 생략되었으나, 명시적으로 추가하여 description getter의 일관성을 높였습니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift",
"line": 33,
"code_snippet": " public let label: String?",
"body": "🔴 [P1] Critical\n\nlabel 필드를 옵셔널로 변경한 것은 API 응답의 유연성을 확보하고 디코딩 실패 가능성을 줄이는 좋은 변경입니다. 이는 런타임 크래시를 방지하는 중요한 개선 사항입니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift",
"line": 44,
"code_snippet": " label: label ?? "",",
"body": "🔵 [P4] Readability\n\n이전 변경에서 label이 옵셔널이 되었으므로, nil 병합 연산자를 사용하여 기본값을 제공하는 것은 안전하고 좋은 접근 방식입니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift",
"line": 28,
"code_snippet": " isLiked: isLiked ?? false,",
"body": "🔵 [P4] Readability\n\nisLikedisMyPerspective 필드를 옵셔널 바인딩으로 안전하게 처리하여 nil 값에 대한 안정성을 확보했습니다. 도메인 모델에서 불필요한 옵셔널 타입을 제거하여 사용 편의성을 높였습니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift",
"line": 30,
"code_snippet": " createdAt: createdAt.flatMap(Self.parseISO8601)",
"body": "🔵 [P4] Readability\n\nflatMap을 사용하여 createdAt 문자열을 Date로 변환하는 로직을 통합했습니다. 이와 함께 parseISO8601 유틸리티 메서드를 추가하여 날짜 파싱을 재사용 가능하고 안정적으로 만들었습니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift",
"line": 34,
"code_snippet": " private static func parseISO8601(_ value: String) -> Date? {",
"body": "🔵 [P4] Readability\n\nISO8601 날짜 문자열 파싱을 위한 전용 static func를 추가하여 날짜 처리 로직을 중앙화하고 재사용성을 높였습니다. fractional seconds를 선택적으로 처리하는 방식 또한 유연하고 견고합니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift",
"line": 14,
"code_snippet": " philosophers: philosophers.enumerated().map { idx, dto in",
"body": "🔵 [P4] Readability\n\nphilosophers 매핑 시 enumerated()를 사용하여 인덱스에 기반한 fallback label (fallbackLabel)을 제공하는 로직이 추가되었습니다. 이 패턴은 API 응답이 불완전할 때 클라이언트에서 의미 있는 기본값을 제공하는 좋은 방법입니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift",
"line": 25,
"code_snippet": " /// 응답에서 label 이 누락된 경우 사용할 인덱스 기반 폴백 (A/B/C/D…).",
"body": "🔵 [P4] Readability\n\nfallbackLabel 헬퍼 함수를 통해 API 응답에서 label이 누락될 경우를 대비한 복구 로직을 구현했습니다. 이는 데이터 무결성을 보장하고 UI 렌더링 시 빈 상태를 방지하는 데 유용합니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift",
"line": 33,
"code_snippet": " func toDomain(fallbackLabel: String) -> ScenarioPhilosopher {",
"body": "🔵 [P4] Readability\n\nScenarioPhilosopherDTOtoDomain 메서드에 fallbackLabel 파라미터를 추가하여, 데이터 매핑 시 외부에서 제공하는 대체 라벨을 사용할 수 있게 했습니다. 이는 BattleScenarioDataDTO 내의 enumerated().map 로직과 연동되어 유연성을 높입니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift",
"line": 10,
"code_snippet": " func toDomain() -> BattleVoteStats {",
"body": "🔵 [P4] Readability\n\n새로운 BattleVoteStats 도메인 엔티티로 매핑하는 toDomain() 함수를 추가했습니다. 데이터 계층에서 응답 데이터를 도메인 계층 모델로 변환하는 역할을 잘 수행하고 있습니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift",
"line": 18,
"code_snippet": " /// 2026-05-22T13:28:16.697Z 형태의 ISO8601 (fractional seconds 포함) 파싱.",
"body": "🔵 [P4] Readability\n\nISO8601 날짜 파싱 유틸리티 함수를 추가하여, API에서 반환되는 다양한 형식의 날짜 문자열을 안정적으로 Date 타입으로 변환할 수 있도록 했습니다. 재사용성이 높고 견고합니다."
},
{
"path": "Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift",
"line": 36,
"code_snippet": " ratio: ratio > 1 ? ratio / 100.0 : ratio,",
"body": "🔵 [P4] Readability\n\nratio 값이 1보다 클 경우 100으로 나누어 퍼센트 값을 소수점으로 변환하는 로직을 추가했습니다. 이는 API 응답 데이터 형식을 도메인 모델에 맞게 정규화하는 좋은 예시입니다."
},
{
"path": "Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift",
"line": 21,
"code_snippet": " @discardableResult",
"body": "🔵 [P4] Readability\n\nload 메서드가 오디오 로딩 성공/실패 여부를 Bool로 반환하도록 변경되었습니다. @discardableResult를 추가하여 반환값을 반드시 사용할 필요는 없지만, 필요한 경우 오디오 로딩 실패 감지 로직에 활용할 수 있도록 명확히 했습니다."
},
{
"path": "Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift",
"line": 86,
"code_snippet": " // 자산이 실제 재생 가능한지 확인 → 실패 시 오류 배너 노출 트리거.",
"body": "🔴 [P1] Critical\n\nAVPlayerItem.asset.load(.isPlayable)을 사용하여 오디오 로딩 가능 여부를 명시적으로 확인하는 로직이 추가되었습니다. 이는 오디오 재생 실패 상황을 미리 감지하고 사용자에게 오류 배너를 통해 피드백을 제공하는 중요한 개선입니다. 런타임 오류를 줄이고 사용자 경험을 향상시킵니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 35,
"code_snippet": " throw BattleError.backendError(message)",
"body": "🟠 [P2] Major\n\n에러 타입을 AuthError에서 BattleError로 변경하여 도메인별 에러 분류를 명확히 했습니다. 이는 Clean Architecture의 원칙을 따르며, 에러 핸들링의 명확성과 유지보수성을 높입니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 58,
"code_snippet": " public func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats {",
"body": "🔵 [P4] Readability\n\n새로운 fetchVoteStats 메서드가 추가되었습니다. 이는 BattleInterface 프로토콜 변경에 따라 투표 통계 데이터를 가져오는 기능을 구현한 것으로, 새로운 댓글/큐레이팅 화면에 필요한 데이터 소스를 제공합니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 72,
"code_snippet": " public func submitPostVote(",
"body": "🔵 [P4] Readability\n\n최종 투표 제출을 위한 submitPostVote 메서드가 추가되었습니다. 기존 사전 투표와 유사한 로직으로 구현되어 있습니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 89,
"code_snippet": " public func fetchPerspectives(",
"body": "🔵 [P4] Readability\n\n댓글(관점) 목록을 가져오는 fetchPerspectives 메서드가 추가되었습니다. 페이징, 필터, 정렬 기능을 포함하며, BattleInterface의 확장을 반영합니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 117,
"code_snippet": " public func createPerspective(",
"body": "🔵 [P4] Readability\n\n댓글(관점)을 생성하는 createPerspective 메서드가 추가되었습니다. contentoptionId를 받아 새로운 관점을 등록합니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 135,
"code_snippet": " guard let perspective = try await fetchMyPerspective(battleId: battleId) else {",
"body": "🟠 [P2] Major\n\ncreatePerspective 성공 후 fetchMyPerspective를 호출하여 새로 생성된 관점의 전체 데이터를 가져오는 패턴은, 생성 API가 최소한의 정보만 반환할 때 유용합니다. 이렇게 함으로써 UI에 필요한 모든 정보가 포함된 도메인 모델을 일관되게 제공할 수 있습니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 144,
"code_snippet": " public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? {",
"body": "🔵 [P4] Readability\n\n내 관점(my perspective)을 가져오는 fetchMyPerspective 메서드가 추가되었습니다. 사용자의 참여 여부에 따라 nil을 반환하거나, API 오류 시 Log.debug로 처리하는 등 유연한 오류 핸들링을 적용했습니다."
},
{
"path": "Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift",
"line": 173,
"code_snippet": " public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage {",
"body": "🔵 [P4] Readability\n\n흥미 기반 추천 배틀 목록을 가져오는 fetchRecommendedBattles 메서드가 추가되었습니다. 큐레이팅 화면에 필요한 데이터를 제공합니다."
},
{
"path": "Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentRepositoryImplCommentInterface 프로토콜을 구현하며, DomainInterface, Entity, Model, Service 모듈에 올바르게 의존합니다. Clean Architecture 원칙에 따라 데이터 계층의 역할을 잘 수행하고 있습니다."
},
{
"path": "Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nPerspectiveRepositoryImplPerspectiveInterface 프로토콜을 구현하며, 관점 및 대댓글 관련 CRUD 작업을 담당합니다. CommentRepositoryImpl과 마찬가지로 Clean Architecture 원칙을 잘 따르고 있습니다."
},
{
"path": "Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift",
"line": 145,
"code_snippet": " return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: true)",
"body": "🟠 [P2] Major\n\nlikePerspectiveunlikePerspective 메서드에서 CommentLikeResult를 생성할 때 isLiked 값을 명시적으로 true 또는 false로 설정했습니다. 이는 API 응답에서 isLiked 필드가 누락될 수 있다는 주석(CommentLikeDataDTO.swiftisLiked: Bool?)을 고려한 견고한 데이터 매핑 로직입니다."
},
{
"path": "Projects/Data/Service/Sources/Battle/BattleService.swift",
"line": 16,
"code_snippet": " case postVote(battleId: Int, body: PreVoteRequest)",
"body": "🔵 [P4] Readability\n\npostVote, voteStats, perspectives, createPerspective, myPerspective, recommendations 등 새로운 배틀 관련 서비스 케이스들이 추가되었습니다. 기능 확장을 위한 적절한 변경입니다."
},
{
"path": "Projects/Data/Service/Sources/Battle/BattleService.swift",
"line": 77,
"code_snippet": " guard let dict = query.toDictionary else { return nil }",
"body": "🔵 [P4] Readability\n\nPerspectivesQueryRequest를 딕셔너리로 변환하고, 딕셔너리가 비어있으면 nil을 반환하여 불필요한 빈 쿼리 파라미터 전송을 방지합니다. 이는 API 요청의 효율성을 높이는 좋은 접근 방식입니다."
},
{
"path": "Projects/Data/Service/Sources/Comment/CommentService.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentServicePieckeDomain.comment를 사용하여 댓글 관련 API 엔드포인트를 정의합니다. likeunlike 작업에 대한 HTTP 메서드(POST, DELETE)가 올바르게 매핑되어 있습니다."
},
{
"path": "Projects/Data/Service/Sources/Perspective/PerspectiveService.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nPerspectiveServicePieckeDomain.perspective를 사용하여 관점 및 대댓글 관련 API 엔드포인트를 정의합니다. 다양한 CRUD 작업에 대한 urlPathmethod가 올바르게 매핑되어 있습니다."
},
{
"path": "Projects/Data/Service/Sources/Perspective/PerspectiveService.swift",
"line": 76,
"code_snippet": " case .updateComment, .updatePerspective:",
"body": "🟠 [P2] Major\n\n댓글 및 관점 업데이트에 .patch HTTP 메서드를 사용하는 것은 PR 설명(대댓글 수정 PUT → PATCH 메서드 버그 수정)과 일치하며, RESTful API 디자인을 올바르게 따르는 중요한 수정 사항입니다. 이는 이전 PUT 사용으로 인해 발생했을 수 있는 잠재적 문제를 해결합니다."
},
{
"path": "Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift",
"line": 13,
"code_snippet": " /// 음원을 로드하고 재생 가능 여부를 반환한다. (false = 오디오 로딩 실패)",
"body": "🔵 [P4] Readability\n\nload 메서드의 시그니처를 async -> Bool로 변경하여 오디오 로딩 성공/실패 여부를 명시적으로 반환하게 했습니다. 이는 오디오 재생 오류 처리 기능 구현을 위해 필수적인 변경이며, 주석으로 그 목적을 명확히 설명하고 있습니다."
},
{
"path": "Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift",
"line": 16,
"code_snippet": " func submitPostVote(",
"body": "🔵 [P4] Readability\n\n최종 투표 제출, 투표 통계 조회, 관점(댓글) 목록 조회 및 생성, 내 관점 조회, 추천 배틀 목록 조회 등 새로운 기능들을 반영하는 메서드들이 BattleInterface에 추가되었습니다. 이는 도메인 인터페이스가 새로운 비즈니스 로직을 지원하도록 확장되었음을 보여줍니다."
},
{
"path": "Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentInterface 프로토콜과 기본 구현체(DefaultCommentRepositoryImpl)가 추가되었습니다. 이는 댓글 좋아요/취소 기능을 도메인 계층에서 추상화하고, 의존성 주입을 위한 DependencyKey로 설정하여 TCA와 Clean Architecture의 통합을 강화합니다."
},
{
"path": "Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nPerspectiveInterface 프로토콜과 기본 구현체(DefaultPerspectiveRepositoryImpl)가 추가되었습니다. 관점 및 대댓글 CRUD, 좋아요/취소, 신고 등 핵심 비즈니스 로직을 추상화하여 도메인 계층의 독립성을 유지합니다."
},
{
"path": "Projects/Domain/Entity/Sources/Error/BattleError.swift",
"line": 1,
"code_snippet": "//",
"body": "🟠 [P2] Major\n\nBattleError가 새로운 BattleError enum으로 정의되어 배틀 도메인에 특화된 오류를 표현하고 있습니다. LocalizedError를 준수하며 errorDescription, failureReason, recoverySuggestion을 제공하여 사용자에게 명확한 오류 메시지를 전달하는 것은 사용자 경험을 크게 향상시키며, 코드 내에서 에러 처리의 일관성을 높입니다."
},
{
"path": "Projects/Domain/Entity/Sources/Error/CommentError.swift",
"line": 1,
"code_snippet": "//",
"body": "🟠 [P2] Major\n\nCommentError가 새로운 CommentError enum으로 정의되어 댓글 도메인에 특화된 오류를 표현하고 있습니다. BattleError와 동일하게 LocalizedError를 준수하여 사용자에게 친화적인 오류 메시지를 제공합니다."
},
{
"path": "Projects/Domain/Entity/Sources/Error/PerspectiveError.swift",
"line": 1,
"code_snippet": "//",
"body": "🟠 [P2] Major\n\nPerspectiveError가 새로운 PerspectiveError enum으로 정의되어 관점 도메인에 특화된 오류를 표현하고 있습니다. LocalizedError를 준수하여 사용자에게 명확한 오류 정보를 제공하며, 에러 처리의 일관성을 유지합니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift",
"line": 126,
"code_snippet": " case completed = "COMPLETED"",
"body": "🔵 [P4] Readability\n\nBattleStep enum에 completed 케이스를 추가하여 배틀의 최종 상태를 명확히 표현했습니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nBattleVoteStats 엔티티가 추가되어 투표 통계 데이터를 표현합니다. 이 모델은 댓글 화면 상단의 옵션별 비율 및 참여자 수 표시에 활용될 것입니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nRecommendedBattlePageRecommendedBattle 엔티티가 추가되어 큐레이팅 화면에 필요한 추천 배틀 데이터를 표현합니다. 커서 페이지네이션 지원도 명시되어 있습니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nVoteSummary, VoteOptionSummary, CommentFilter, CommentSort 등 댓글 화면의 UI 요소를 위한 도메인 모델들이 추가되었습니다. UI와 비즈니스 로직 사이의 매핑을 담당하는 Entity 계층의 역할에 부합합니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nBattlePerspectivePageBattlePerspective 엔티티가 추가되어 댓글(관점) 목록 데이터를 표현합니다. BattlePerspectiveSort enum을 통해 정렬 옵션도 명확히 정의되어 있습니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Comment/Comment.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentOption, CommentAuthor, Comment, CommentReplyItem, CommentSortType, CommentTab, CommentItem 등 댓글 화면의 다양한 UI 요소들을 위한 엔티티가 추가되었습니다. 특히 CommentReplyItemCommentItem은 API 응답을 화면 모델로 변환하는 init 메서드를 포함하여 데이터 매핑 로직을 캡슐화했습니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Comment/Comment.swift",
"line": 111,
"code_snippet": " let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID()",
"body": "🔵 [P4] Readability\n\ndeterministicUUID를 사용하여 commentId로부터 예측 가능한 UUID를 생성하는 것은 SwiftUI ForEach에서 안정적인 id를 제공하는 좋은 방법입니다. UI 업데이트 성능과 안정성에 기여합니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Comment/Comment.swift",
"line": 132,
"code_snippet": " private static func relativeTimeString(from date: Date?) -> String {",
"body": "🔵 [P4] Readability\n\nrelativeTimeString 유틸리티 함수는 Date 객체로부터 "방금 전", "X분 전", "X시간 전", "X일 전"과 같은 사용자 친화적인 시간 문자열을 생성합니다. 이는 댓글 UI에서 시간 정보를 효과적으로 표시하는 데 유용합니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentLikeResult 엔티티가 추가되어 댓글 또는 관점의 좋아요/취소 액션 결과를 표현합니다. 이는 좋아요 상태 및 카운트 업데이트 로직을 단순화하는 데 기여합니다."
},
{
"path": "Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nPerspectiveCommentPagePerspectiveComment 엔티티가 추가되어 대댓글 목록 데이터를 표현합니다. PerspectiveCommentMutationResult는 대댓글 작성/수정 결과를 캡슐화합니다."
},
{
"path": "Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nBattleUseCaseImplBattleInterface를 구현하고 battleRepository에 의존성을 가짐으로써, Presentation Layer와 Data Layer 사이에 추상화된 비즈니스 로직 계층을 제공합니다. 이는 Clean Architecture를 준수하는 올바른 패턴입니다."
},
{
"path": "Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentUseCaseImplCommentInterface를 구현하고 commentRepository에 의존성을 가짐으로써, 댓글 관련 비즈니스 로직을 캡슐화합니다. 이는 Clean Architecture의 UseCase 역할에 부합합니다."
},
{
"path": "Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nHomeUseCaseImplHomeInterface를 구현하고 homeRepository에 의존성을 가짐으로써, 홈 화면 데이터 관련 비즈니스 로직을 담당합니다. 이는 Clean Architecture의 UseCase 역할에 부합합니다."
},
{
"path": "Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nPerspectiveUseCaseImplPerspectiveInterface를 구현하고 perspectiveRepository에 의존성을 가짐으로써, 관점 및 대댓글 관련 비즈니스 로직을 중앙화합니다. 이는 Clean Architecture의 UseCase 역할에 부합합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 31,
"code_snippet": " public var hasAudioError: Bool = false",
"body": "🔵 [P4] Readability\n\nhasAudioError 상태 변수가 추가되어 오디오 로딩 실패 시 UI에 오류 배너를 표시하는 데 사용됩니다. 이는 새로운 오류 처리 기능을 구현하기 위한 중요한 상태입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 40,
"code_snippet": " public var visibleNodeIds: [Int] = []",
"body": "🔵 [P4] Readability\n\nvisibleNodeIdsisWaitingForNodeSelection 상태 변수가 추가되어 채팅방의 시나리오 진행 상태를 관리합니다. 이는 채팅 메시지 및 인터랙티브 옵션 표시 로직에 직접적으로 영향을 미치며, 사용자 경험을 동적으로 제어하는 데 필수적입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 57,
"code_snippet": " let nodes = visibleNodes(in: scenario)",
"body": "🔵 [P4] Readability\n\nmessages computed property가 이제 visibleNodes(in:) 헬퍼 함수를 사용하여 시나리오의 전체 노드 대신 현재 화면에 표시되어야 할 노드들만 기반으로 메시지를 생성합니다. 이는 채팅 스크롤 성능과 로직의 명확성을 향상시킵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 146,
"code_snippet": " guard isWaitingForNodeSelection else { return [] }",
"body": "🔵 [P4] Readability\n\ninteractiveOptions computed property가 isWaitingForNodeSelection 상태에 따라 옵션을 반환하도록 변경되었습니다. 이는 사용자가 노드 선택을 기다리는 상태에서만 옵션이 표시되도록 하여 UI 로직의 정확성을 높입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 162,
"code_snippet": " hasFinishedListening = Self.hasListenedBefore(battleId: battleId)",
"body": "🔵 [P4] Readability\n\nChatRoomFeature.Stateinit에서 UserDefaults를 사용하여 hasFinishedListening 상태를 초기화합니다. 이는 사용자가 이전에 콘텐츠를 끝까지 들었는지 여부를 유지하여 재진입 시 시킹/건너뛰기 허용 여부를 결정하는 데 사용됩니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 173,
"code_snippet": " public func visibleNodes(in scenario: BattleScenario) -> [ScenarioNode] {",
"body": "🔵 [P4] Readability\n\nvisibleNodes(in:), nodeStartTime(for:), nodeEndTime(for:) 헬퍼 함수들이 추가되어 시나리오 노드들의 가시성, 시작 및 종료 시간을 계산하는 로직을 캡슐화했습니다. 이는 복잡한 채팅 시나리오 진행 로직을 관리하는 데 필수적입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 203,
"code_snippet": " case onDisappear",
"body": "🔵 [P4] Readability\n\nonDisappear 액션을 추가하여 뷰가 사라질 때 오디오 플레이어를 일시 정지하고 관련 옵저버를 취소하는 등 리소스 정리를 수행하도록 했습니다. 이는 메모리 관리 및 백그라운드 재생 제어에 중요합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 221,
"code_snippet": " case scenarioResponse(Result<BattleScenario, BattleError>)",
"body": "🟠 [P2] Major\n\nscenarioResponse의 에러 타입을 AuthError에서 BattleError로 변경했습니다. 이는 BattleRepositoryImpl의 에러 타입 변경과 일관성을 유지하며, 도메인별 에러 핸들링을 강화합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 224,
"code_snippet": " case audioLoadFailed",
"body": "🔵 [P4] Readability\n\naudioLoadFaileddismissAudioError 액션이 추가되어 오디오 로딩 실패 상태를 관리하고, 이와 연동하여 UI에 오류 배너를 표시하고 자동으로 숨기는 로직을 구현합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 235,
"code_snippet": " case requestFinalVote(battleId: Int)",
"body": "🔵 [P4] Readability\n\nrequestFinalVote 델리게이트 액션이 추가되어 채팅방에서 최종 투표 화면으로 전환을 요청할 수 있게 되었습니다. 이는 ChatRoomFeature가 상위 Coordinator에 네비게이션 변경을 요청하는 표준 TCA 패턴을 따릅니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 244,
"code_snippet": " @dependency(\.battleUseCase) private var battleUseCase",
"body": "🟠 [P2] Major\n\nbattleRepository 대신 battleUseCase를 의존성으로 주입하도록 변경했습니다. 이는 Presentation Layer가 Domain Layer의 UseCase에 의존하고, UseCase가 Data Layer의 Repository에 의존하는 Clean Architecture 원칙을 준수하는 올바른 변경입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 284,
"code_snippet": " case .onDisappear:",
"body": "🔵 [P4] Readability\n\nonDisappear 액션이 처리되어 오디오 재생을 중단하고 audioObserver Effect를 취소합니다. 이는 뷰 계층이 활성 상태가 아닐 때 불필요한 리소스 사용을 막는 좋은 방법입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 300,
"code_snippet": " state.currentNodeId = state.scenario?.startNodeId",
"body": "🔵 [P4] Readability\n\nrefreshTappedcurrentNodeIdvisibleNodeIds를 초기화하고, isWaitingForNodeSelection, selectedOptionLabel도 리셋합니다. 이는 채팅방을 "새로고침"할 때 일관된 시작 상태를 보장합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 349,
"code_snippet": " if !state.visibleNodeIds.contains(option.nextNodeId) {",
"body": "🔵 [P4] Readability\n\n사용자가 상호작용 옵션을 선택할 때 visibleNodeIds에 다음 노드를 추가하여 채팅 메시지 표시를 업데이트하는 로직이 추가되었습니다. 이는 동적인 채팅 UI를 구현하는 데 필수적인 부분입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 353,
"code_snippet": " state.isWaitingForNodeSelection = false",
"body": "🔵 [P4] Readability\n\n옵션 선택 시 isWaitingForNodeSelectionfalse로 설정하고, 선택된 노드의 시작 시간으로 currentTime을 설정한 후 재생을 시작합니다. 이 로직은 사용자의 선택에 따라 시나리오 진행을 제어합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 385,
"code_snippet": " state.hasAudioError = false",
"body": "🔵 [P4] Readability\n\nloadAudio 액션 처리 시 hasAudioErrorfalse로 초기화하여, 새로운 오디오 로딩 시 이전 오류 상태를 제거합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 387,
"code_snippet": " let isPlayable = await player.load(url: url)",
"body": "🔴 [P1] Critical\n\naudioPlayer.load 메서드의 반환값을 사용하여 오디오 로딩 가능 여부를 확인하고, 실패 시 audioLoadFailed 액션을 디스패치합니다. 이 로직은 오디오 재생 오류를 사용자에게 즉시 알리는 FloatingErrorView와 직접 연결되어, 사용자 경험을 크게 개선합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 422,
"code_snippet": " if state.visibleNodeIds.isEmpty {",
"body": "🔵 [P4] Readability\n\nscenarioResponse 처리 시 visibleNodeIds가 비어있으면 startNodeId로 초기화합니다. 이는 시나리오 로딩 후 채팅 UI의 초기 상태를 올바르게 설정하는 데 중요합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 441,
"code_snippet": " if let effect = advanceNodeIfNeeded(state: &state, time: time) {",
"body": "🔵 [P4] Readability\n\nadvanceNodeIfNeeded 헬퍼 함수를 도입하여 playerTimeUpdated 로직을 분리했습니다. 이 함수는 현재 노드 종료 시간 또는 상호작용 노드 여부에 따라 자동으로 다음 노드로 진행하거나 사용자 입력을 기다리는 상태로 전환하는 복잡한 로직을 처리합니다. 리듀서의 가독성과 유지보수성을 크게 향상시킵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 447,
"code_snippet": " if !state.hasFinishedListening {",
"body": "🔵 [P4] Readability\n\n오디오 재생이 거의 끝났을 때 (totalDuration - 0.5) hasFinishedListening 상태를 업데이트하고, UserDefaults에 저장하여 사용자가 해당 배틀을 완주했음을 기록합니다. 이는 이후 재진입 시 시킹(드래그) 기능을 허용하는 기준이 됩니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 463,
"code_snippet": " case .audioLoadFailed:",
"body": "🔵 [P4] Readability\n\naudioLoadFailed 액션이 발생하면 hasAudioErrortrue로 설정하여 오류 배너를 표시하고, 3초 후 dismissAudioError 액션을 디스패치하여 배너를 자동으로 숨기는 Effect를 시작합니다. 이는 사용자에게 일시적인 오류 피드백을 제공하는 효과적인 방법입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 479,
"code_snippet": " private func advanceNodeIfNeeded(",
"body": "🔵 [P4] Readability\n\n채팅 시나리오 노드 진행 로직을 advanceNodeIfNeeded 프라이빗 메서드로 분리했습니다. 이 메서드는 현재 오디오 시간과 노드 종료 시간을 비교하여 다음 노드로 자동 진행하거나, 인터랙티브 옵션이 있을 경우 사용자 입력을 기다리는 상태로 전환하는 역할을 담당합니다. 이는 리듀서의 핵심 로직을 간결하게 유지하는 데 기여합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift",
"line": 522,
"code_snippet": " let battleId = state.battleId",
"body": "🔵 [P4] Readability\n\n최종 투표 알럿에서 '확인' 버튼을 탭했을 때, audioPlayer를 일시 정지하고 requestFinalVote 델리게이트 액션을 전송하여 최종 투표 화면으로의 전환을 요청합니다. 이는 최종 투표 흐름의 시작점을 명확히 합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift",
"line": 21,
"code_snippet": " private enum Metric {",
"body": "🔵 [P4] Readability\n\n매직 넘버들을 Metric enum으로 묶어 관리합니다. 이는 코드의 가독성을 높이고, UI 요소의 크기나 간격 변경 시 한 곳에서 관리할 수 있게 하여 유지보수성을 향상시킵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift",
"line": 48,
"code_snippet": " .overlay(alignment: .top) {",
"body": "🔵 [P4] Readability\n\nFloatingErrorView를 오버레이로 추가하여 hasAudioError 상태에 따라 오디오 로딩 실패 메시지를 표시합니다. transitionanimation을 적용하여 부드러운 UI 변화를 제공하며, 사용자에게 중요한 시각적 피드백을 전달합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift",
"line": 61,
"code_snippet": " .onDisappear { send(.onDisappear) }",
"body": "🔵 [P4] Readability\n\n.onDisappear 뷰 수정자를 사용하여 뷰가 화면에서 사라질 때 onDisappear 액션을 store로 전송합니다. 이는 리소스 정리와 같은 필요한 작업을 수행할 수 있도록 합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentFeature는 댓글(관점) 화면의 전체 로직을 관리하는 TCA 리듀서입니다. @ObservableState를 사용하여 UI 상태를 선언하고 Async, Inner, View, Scope, Delegate 액션으로 명확하게 역할을 분리했습니다. battleUseCase, commentUseCase, perspectiveUseCase에 의존하여 Clean Architecture 원칙을 준수합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 45,
"code_snippet": " public var menuTargetComment: CommentItem? {",
"body": "🔵 [P4] Readability\n\nmenuTargetComment computed property는 menuTargetCommentID에 따라 현재 메뉴가 열린 댓글 항목을 반환합니다. 이는 UI 로직에서 특정 댓글에 대한 접근을 용이하게 합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 163,
"code_snippet": " case .binding(\.commentText):",
"body": "🔵 [P4] Readability\n\ncommentText 바인딩 액션에서 텍스트 길이를 200자로 제한하는 로직을 추가했습니다. 이는 사용자 입력 유효성 검사를 리듀서 내에서 처리하는 좋은 예시입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 201,
"code_snippet": " .send(.async(.fetchBattle)),",
"body": "🔵 [P4] Readability\n\nonAppear 액션 처리 시 배틀 상세, 내 관점, 투표 통계, 그리고 댓글 목록을 비동기적으로 가져오는 여러 Effect를 병합하여 디스패치합니다. 이는 화면 초기 로딩에 필요한 모든 데이터를 효율적으로 가져오는 방법입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 213,
"code_snippet": " return .send(.delegate(.openCuration(battleId: state.battleId)))",
"body": "🔵 [P4] Readability\n\nforwardTapped 액션이 발생하면 openCuration 델리게이트 액션을 전송하여 큐레이팅 화면으로 이동을 요청합니다. 이는 CommentView가 상위 Coordinator에 네비게이션 변경을 요청하는 TCA 패턴을 따릅니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 236,
"code_snippet": " state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1",
"body": "🔵 [P4] Readability\n\n댓글 좋아요/취소 시 isLiked 상태를 토글하고, likeCount를 즉시 업데이트하는 낙관적 업데이트(optimistic update)를 수행합니다. 이는 사용자에게 빠른 UI 피드백을 제공하며, 이후 API 응답에 따라 실제 상태를 확정합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 248,
"code_snippet": " state.menuTargetCommentID = (state.menuTargetCommentID == id) ? nil : id",
"body": "🔵 [P4] Readability\n\ncommentMenu(.more) 액션 처리 시 menuTargetCommentID를 토글하여 해당 댓글의 "…" 메뉴를 열거나 닫습니다. 이는 단일 메뉴만 활성화되도록 하는 UI 로직입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 254,
"code_snippet": " state.editingPerspectiveId = pid",
"body": "🔵 [P4] Readability\n\ncommentMenu(.edit) 액션 처리 시 editingPerspectiveId를 설정하고, commentText에 기존 내용을 채워 댓글 수정 모드로 진입합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 261,
"code_snippet": " state.deleteTargetPerspectiveId = pid",
"body": "🔵 [P4] Readability\n\ncommentMenu(.delete) 액션 처리 시 deleteTargetPerspectiveId를 설정하고 CustomAlert.deletePerspective()를 표시합니다. 이는 삭제 전 사용자에게 확인을 요청하는 표준적인 UI 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 265,
"code_snippet": " state.reportTargetCommentID = id",
"body": "🔵 [P4] Readability\n\ncommentMenu(.report) 액션 처리 시 reportTargetCommentID를 설정하고 CustomAlert.report()를 표시합니다. 이는 신고 전 사용자에게 확인을 요청하는 표준적인 UI 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 278,
"code_snippet": " return .send(.async(.fetchPerspectives(reset: true)))",
"body": "🔵 [P4] Readability\n\n필터 또는 정렬 탭을 탭했을 때 fetchPerspectives(reset: true) 액션을 디스패치하여, 기존 댓글 목록을 초기화하고 새로운 기준으로 데이터를 다시 가져오도록 합니다. 이는 UI와 데이터의 일관성을 유지하는 데 중요합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 288,
"code_snippet": " state.isSubmitting = true",
"body": "🔵 [P4] Readability\n\nsendTapped 액션 처리 시 isSubmitting 상태를 true로 설정하고 commentText를 비워 중복 제출을 방지하고 UI를 업데이트합니다. 이후 createPerspective 또는 updatePerspective 액션을 디스패치합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 309,
"code_snippet": " if let pid = state.deleteTargetPerspectiveId {",
"body": "🔵 [P4] Readability\n\ncustomAlert의 '확인' 액션 처리 시, deleteTargetPerspectiveId 또는 reportTargetCommentID에 따라 적절한 비동기 액션을 디스패치합니다. 이는 알럿의 목적에 따른 분기 로직을 명확히 합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 420,
"code_snippet": " state.currentNodeId = scenario.startNodeId",
"body": "🔵 [P4] Readability\n\nbattleResponse 처리 시 배틀 상세 정보로 titlevoteSummary 옵션들을 업데이트합니다. 또한, myOptionId가 설정되지 않은 경우 userVoteStatus를 기반으로 myOptionId를 초기화합니다. 이는 UI에 필요한 초기 데이터를 준비합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 504,
"code_snippet": " state.perspectiveId = perspective?.perspectiveId",
"body": "🔵 [P4] Readability\n\nmyPerspectiveResponse 처리 시 perspectiveIdmyOptionId를 업데이트합니다. 또한, myPid가 설정되면 기존 comments 목록에서 isMine 플래그를 업데이트하여 "내 댓글" 표시를 처리합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 536,
"code_snippet": " if let myPid, comment.perspectiveId == myPid {",
"body": "🔵 [P4] Readability\n\nperspectivesResponse 처리 시 서버 isMyPerspective 값이 누락되거나 false일 경우에도 state.perspectiveId와 일치하면 isMinetrue로 설정합니다. 이는 내 댓글을 정확히 표시하기 위한 클라이언트 측 보정 로직입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 545,
"code_snippet": " let likeEffects: [Effect] = mapped.compactMap { item in",
"body": "🟠 [P2] Major\n\nfetchPerspectives 응답 후, 각 관점의 perspectiveId를 사용하여 fetchPerspectiveLikes 액션을 디스패치합니다. 이는 댓글 목록 로딩 후 각 댓글의 좋아요 수를 개별적으로 가져와 최신화하는 효율적인 방법입니다. 다만, compactMap으로 여러 Effect를 생성하고 merge하는 것이므로 Effect 합성 패턴으로 적절합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 570,
"code_snippet": " return .send(.async(.fetchPerspectives(reset: true)))",
"body": "🔵 [P4] Readability\n\n댓글 생성 성공 후 fetchPerspectives(reset: true) 액션을 디스패치하여 댓글 목록을 새로고침합니다. 이는 새로 생성된 댓글이 목록에 즉시 반영되도록 합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 585,
"code_snippet": " // GET 은 좋아요 "수" 조회 용도. isLiked 는 목록 응답 값을 신뢰하고 덮어쓰지 않는다.",
"body": "🔵 [P4] Readability\n\nperspectiveLikesResponse 처리 시 좋아요 count만 업데이트하고 isLiked 상태는 변경하지 않습니다. 이는 fetchPerspectiveLikes가 좋아요 수만을 조회하는 용도임을 명확히 하며, isLiked 상태는 댓글 목록 응답에서 오는 값을 신뢰하는 설계 결정에 부합합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift",
"line": 594,
"code_snippet": " private func makeSummary(",
"body": "🔵 [P4] Readability\n\nmakeSummary 헬퍼 함수는 BattleVoteStats API 응답을 VoteSummary UI 모델로 변환합니다. 옵션 제목, 대표자, 이미지 URL 등 누락될 수 있는 필드에 대해 fallback 값을 사용하여 견고성을 높였습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentView는 댓글 화면의 UI를 구현합니다. @Bindable@FocusState를 사용하고, 모든 하위 뷰는 private func (@ViewBuilder) 형태로 잘 분리되어 있습니다. 이는 SwiftUI의 권장 사항과 프로젝트의 규칙을 따르는 좋은 코드 구조입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift",
"line": 37,
"code_snippet": " .simultaneousGesture(",
"body": "🔵 [P4] Readability\n\nsimultaneousGesture를 사용하여 좌우 스와이프 제스처를 감지하고 filterTapped 액션을 디스패치합니다. 이는 댓글 필터 탭 간의 직관적인 전환 경험을 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift",
"line": 61,
"code_snippet": " hasAnimatedVoteProgress = true",
"body": "🔵 [P4] Readability\n\nonAppearhasAnimatedVoteProgress 상태를 업데이트하여 투표 진행률 바에 애니메이션을 적용합니다. 이는 사용자에게 시각적으로 즐거운 경험을 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift",
"line": 70,
"code_snippet": " private func inlineMenu(for comment: CommentItem) -> some View {",
"body": "🔵 [P4] Readability\n\ninlineMenu 뷰는 댓글 카드 바로 아래에 표시되는 수정/삭제/신고 메뉴를 구현합니다. BottomActionItem을 사용하여 메뉴 항목을 동적으로 생성하고, Capsule 모양의 배경을 적용하여 디자인 가이드라인을 따릅니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift",
"line": 92,
"code_snippet": " private func menuItems(for comment: CommentItem) -> [BottomActionItem] {",
"body": "🔵 [P4] Readability\n\nmenuItems 헬퍼 함수는 댓글이 '내 글'인지 '남의 글'인지에 따라 적절한 액션 항목(수정/삭제 또는 신고)을 반환합니다. 이는 비즈니스 로직을 UI 렌더링 로직에서 분리하는 좋은 방법입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift",
"line": 111,
"code_snippet": " private func adjacentFilter(forward: Bool) -> CommentFilter? {",
"body": "🔵 [P4] Readability\n\nadjacentFilter 헬퍼 함수는 현재 선택된 필터 탭을 기준으로 다음 또는 이전 필터 탭을 결정합니다. 이는 스와이프 제스처로 필터 탭을 변경하는 로직에 활용됩니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentAvatarView는 댓글 및 추천 배틀 카드에서 사용되는 재사용 가능한 아바타 컴포넌트입니다. Kingfisher를 사용하여 이미지를 비동기적으로 로드하며, 이미지 URL이 없거나 로딩 실패 시 fallback 텍스트를 표시합니다. sizeimageScale을 통해 유연한 크기 조절이 가능합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentSkeletonView는 댓글 목록이 로딩 중일 때 표시되는 스켈레톤 UI를 제공합니다. 이는 사용자에게 데이터 로딩 중임을 시각적으로 알려줌으로써 더 나은 사용자 경험을 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentReplyFeature는 대댓글 화면의 전체 로직을 관리하는 TCA 리듀서입니다. @ObservableState를 사용하여 UI 상태를 선언하고, 부모 댓글과 대댓글에 대한 CRUD, 좋아요, 신고 기능을 포함합니다. perspectiveUseCasecommentUseCase에 의존하여 Clean Architecture 원칙을 준수합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 49,
"code_snippet": " public var menuTargetReply: CommentReplyItem? {",
"body": "🔵 [P4] Readability\n\nmenuTargetReply computed property는 menuTargetReplyId에 따라 현재 메뉴가 열린 대댓글 항목을 반환합니다. 이는 UI 로직에서 특정 대댓글에 대한 접근을 용이하게 합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 147,
"code_snippet": " state.replyText = String(state.replyText.prefix(200))",
"body": "🔵 [P4] Readability\n\nreplyText 바인딩 액션에서 텍스트 길이를 200자로 제한하는 로직을 추가했습니다. 이는 사용자 입력 유효성 검사를 리듀서 내에서 처리하는 좋은 예시입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 234,
"code_snippet": " .send(.async(.fetchParent)),",
"body": "🔵 [P4] Readability\n\nonAppear 액션 처리 시 부모 관점 상세, 부모 좋아요 수, 그리고 대댓글 목록을 비동기적으로 가져오는 여러 Effect를 병합하여 디스패치합니다. 이는 화면 초기 로딩에 필요한 모든 데이터를 효율적으로 가져오는 방법입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 245,
"code_snippet": " state.parentComment.likeCount += state.parentComment.isLiked ? 1 : -1",
"body": "🔵 [P4] Readability\n\n부모 댓글 좋아요/취소 시 isLiked 상태를 토글하고 likeCount를 즉시 업데이트하는 낙관적 업데이트를 수행합니다. 이는 사용자에게 빠른 UI 피드백을 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 253,
"code_snippet": " state.replies[index].likeCount += state.replies[index].isLiked ? 1 : -1",
"body": "🔵 [P4] Readability\n\n대댓글 좋아요/취소 시 isLiked 상태를 토글하고 likeCount를 즉시 업데이트하는 낙관적 업데이트를 수행합니다. 이는 사용자에게 빠른 UI 피드백을 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 260,
"code_snippet": " state.replyText = """,
"body": "🔵 [P4] Readability\n\nsendTapped 액션 처리 시 replyText를 비워 중복 제출을 방지하고 UI를 업데이트합니다. 이후 createReply, updateReply, updateParent 액션을 디스패치합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 291,
"code_snippet": " state.menuTargetReplyId = (state.menuTargetReplyId == id) ? nil : id",
"body": "🔵 [P4] Readability\n\n대댓글 MenuAction.more 액션 처리 시 menuTargetReplyId를 토글하여 해당 대댓글의 "…" 메뉴를 열거나 닫습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 298,
"code_snippet": " state.editingCommentId = commentId",
"body": "🔵 [P4] Readability\n\n대댓글 MenuAction.edit 액션 처리 시 editingCommentId를 설정하고 replyText에 기존 내용을 채워 대댓글 수정 모드로 진입합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 306,
"code_snippet": " state.deleteTargetCommentId = commentId",
"body": "🔵 [P4] Readability\n\n대댓글 MenuAction.delete 액션 처리 시 deleteTargetCommentId를 설정하고 CustomAlert.deleteComment()를 표시합니다. 이는 삭제 전 확인을 요청하는 표준적인 UI 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 314,
"code_snippet": " state.reportTargetCommentId = commentId",
"body": "🔵 [P4] Readability\n\n대댓글 MenuAction.report 액션 처리 시 reportTargetCommentId를 설정하고 CustomAlert.report()를 표시합니다. 이는 신고 전 확인을 요청하는 표준적인 UI 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 322,
"code_snippet": " state.parentMenuOpen.toggle()",
"body": "🔵 [P4] Readability\n\n부모 관점 MenuAction.more 액션 처리 시 parentMenuOpen 상태를 토글하여 부모 관점의 "…" 메뉴를 열거나 닫습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 326,
"code_snippet": " state.editingCommentId = nil",
"body": "🔵 [P4] Readability\n\n부모 관점 MenuAction.edit 액션 처리 시 editingParenttrue로 설정하고 replyText에 부모 관점 내용을 채워 부모 관점 수정 모드로 진입합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 330,
"code_snippet": " state.deleteParentPending = true",
"body": "🔵 [P4] Readability\n\n부모 관점 MenuAction.delete 액션 처리 시 deleteParentPendingtrue로 설정하고 CustomAlert.deletePerspective()를 표시합니다. 이는 부모 관점 삭제 전 확인을 요청하는 표준적인 UI 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift",
"line": 334,
"code_snippet": " state.reportParentPending = true",
"body": "🔵 [P4] Readability\n\n부모 관점 MenuAction.report 액션 처리 시 reportParentPendingtrue로 설정하고 CustomAlert.report()를 표시합니다. 이는 부모 관점 신고 전 확인을 요청하는 표준적인 UI 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentReplyView는 대댓글 화면의 UI를 구현합니다. @Bindable@FocusState를 사용하고, 모든 하위 뷰는 private func (@ViewBuilder) 형태로 잘 분리되어 있습니다. 부모 댓글과 대댓글 목록, 그리고 입력 바를 포함하여 전체 화면을 구성합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift",
"line": 53,
"code_snippet": " private func replyMenu(for reply: CommentReplyItem) -> some View {",
"body": "🔵 [P4] Readability\n\nreplyMenuparentMenuView는 각각 대댓글과 부모 관점에 대한 "…" 메뉴를 구현합니다. menuPill 헬퍼 함수를 사용하여 재사용 가능한 캡슐 형태의 메뉴 버튼을 생성했습니다. 이는 디자인 시스템의 일관성을 유지합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCommentReplySkeletonView는 대댓글 화면이 로딩 중일 때 표시되는 스켈레톤 UI를 제공합니다. 부모 댓글과 여러 개의 대댓글에 대한 플레이스홀더를 포함하여 사용자 경험을 향상시킵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 77,
"code_snippet": " case let .routeAction(, action: .preVote(.delegate(.voteSubmitted(battleId, voteMode, )))):",
"body": "🟠 [P2] Major\n\nvoteSubmitted 델리게이트 액션 처리 시 voteMode에 따라 chatRoom 또는 comment 화면으로 이동하도록 로직이 변경되었습니다. 이는 사전/최종 투표에 따른 후속 화면 전환을 정확하게 제어하는 핵심적인 로직입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 86,
"code_snippet": " case let .routeAction(
, action: .preVote(.delegate(.alreadyFinalVoted(battleId)))):",
"body": "🔵 [P4] Readability\n\nalreadyFinalVoted 델리게이트 액션이 발생하면 comment 화면으로 즉시 이동합니다. 이는 사용자가 이미 최종 투표를 완료한 경우의 사용자 흐름을 정의합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 93,
"code_snippet": " case let .routeAction(
, action: .chatRoom(.delegate(.requestFinalVote(battleId)))):",
"body": "🔵 [P4] Readability\n\nrequestFinalVote 델리게이트 액션이 발생하면 preVote 화면으로 post 모드를 지정하여 이동합니다. 이는 채팅방에서 최종 투표를 요청하는 네비게이션 흐름을 처리합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 97,
"code_snippet": " case .routeAction(, action: .comment(.delegate(.dismiss))):",
"body": "🔵 [P4] Readability\n\ncomment, commentReply, curation 화면에서 발생하는 dismiss 델리게이트 액션을 view(.backAction)으로 매핑하여 뒤로가기 네비게이션을 처리합니다. curation 화면의 close 액션은 delegate(.dismiss)를 통해 전체 채팅 플로우를 종료하고 앱 루트(홈)로 돌아갑니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 100,
"code_snippet": " case let .routeAction(
, action: .comment(.delegate(.openReply(comment)))):",
"body": "🔵 [P4] Readability\n\ncomment 화면에서 openReply 델리게이트 액션이 발생하면 CommentReplyFeature.State를 초기화하여 commentReply 화면으로 이동합니다. perspectiveId ?? 0CommentItemperspectiveIdInt? 타입이지만, 실제로는 BattlePerspective에서 생성될 때 항상 Int 값을 가지므로 안전합니다. (기존 BattlePerspective 엔티티에서는 perspectiveId: Int로 선언되어 nil이 될 수 없음)"
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 114,
"code_snippet": " case let .routeAction(, action: .comment(.delegate(.openCuration(battleId)))):",
"body": "🔵 [P4] Readability\n\ncomment 화면에서 openCuration 델리게이트 액션이 발생하면 CurationFeature.State를 초기화하여 큐레이팅 화면으로 이동합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 125,
"code_snippet": " case let .routeAction(
, action: .curation(.delegate(.openBattle(battleId)))):",
"body": "🔵 [P4] Readability\n\ncuration 화면에서 openBattle 델리게이트 액션이 발생하면 preVote 화면으로 이동합니다. 이는 추천 배틀을 탐색하고 시작하는 흐름을 지원합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift",
"line": 165,
"code_snippet": " case comment(CommentFeature)",
"body": "🔵 [P4] Readability\n\nChatScreen enum에 comment, commentReply, curation 케이스를 추가하여 채팅 플로우 내의 새로운 화면들을 정의했습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCurationFeature는 큐레이팅 화면의 로직을 관리하는 TCA 리듀서입니다. @ObservableState를 사용하여 UI 상태를 선언하고, 추천 배틀 목록을 가져오는 기능을 포함합니다. battleUseCase에 의존하여 Clean Architecture 원칙을 준수합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift",
"line": 93,
"code_snippet": " return .send(.async(.fetchRecommendations))",
"body": "🔵 [P4] Readability\n\nonAppearfetchRecommendations 액션을 디스패치하여 추천 배틀 목록을 가져옵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift",
"line": 96,
"code_snippet": " return .send(.delegate(.dismiss))",
"body": "🔵 [P4] Readability\n\nbackButtonTappeddismiss 델리게이트 액션을 전송하여 이전 화면으로 돌아갑니다. closeButtonTapped 시에는 close 델리게이트 액션을 전송하여 전체 채팅 플로우를 종료하고 홈으로 돌아갑니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nCurationView는 큐레이팅 화면의 UI를 구현합니다. 모든 하위 뷰는 private func (@ViewBuilder) 형태로 잘 분리되어 있으며, 로딩 중 스켈레톤 뷰와 빈 상태 메시지를 제공하여 사용자 경험을 향상시킵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift",
"line": 235,
"code_snippet": " subscript(safe index: Int) -> Element? {",
"body": "🔵 [P4] Readability\n\nArray 확장에 subscript(safe:)를 추가하여 인덱스 기반 배열 접근 시 nil을 반환하도록 했습니다. 이는 런타임 인덱스 오류를 방지하는 안전한 프로그래밍 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nShareContent 구조체가 추가되어 공유 시트에 포함될 다양한 콘텐츠(제목, 요약, 해시태그, 옵션 라인, URL, 썸네일, 스냅샷 데이터)를 통합합니다. displayText computed property를 통해 공유 본문 텍스트를 자동으로 생성하는 기능도 포함되어 있습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nShareItem 구조체가 추가되어 SwiftUI의 .sheet(item:) 수정자에 바인딩하여 공유 시트를 트리거하는 데 사용됩니다. 이는 공유 기능 구현을 위한 표준적인 SwiftUI 패턴입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 22,
"code_snippet": " public enum VoteMode: Equatable {",
"body": "🔵 [P4] Readability\n\nVoteMode enum이 추가되어 사전 투표(pre)와 최종 투표(post)를 명확히 구분합니다. 이는 PreVoteFeature가 두 가지 투표 시나리오를 유연하게 처리할 수 있도록 하는 핵심적인 변경입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 36,
"code_snippet": " public var voteMode: VoteMode = .pre",
"body": "🔵 [P4] Readability\n\nvoteMode, myPerspective, customAlert 등 새로운 상태 변수들이 추가되어 PreVoteFeature가 다양한 투표 시나리오와 사용자 상호작용(예: 재투표 확인 알럿)을 관리할 수 있게 되었습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 44,
"code_snippet": " public var primaryButtonTitle: String {",
"body": "🔵 [P4] Readability\n\nprimaryButtonTitle computed property는 voteMode에 따라 "사전 투표하기" 또는 "최종 투표하기" 텍스트를 반환하여 UI에 동적으로 적용합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 73,
"code_snippet": " case shareTapped(snapshot: Data?)",
"body": "🔵 [P4] Readability\n\nshareTapped 액션이 이제 snapshot: Data? 파라미터를 받습니다. 이를 통해 뷰 스냅샷을 캡처하여 공유 시트에 포함할 수 있게 되었으며, prepareShare 액션과 연동되어 풍부한 공유 콘텐츠를 생성합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 80,
"code_snippet": " case fetchMyPerspective",
"body": "🔵 [P4] Readability\n\nfetchMyPerspective, deleteMyPerspective, prepareShare, submitPostVote 등 새로운 비동기 액션들이 추가되어 PreVoteFeature의 기능 확장을 지원합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 96,
"code_snippet": " public struct EmptyResult: Equatable {",
"body": "🔵 [P4] Readability\n\nEmptyResult 구조체가 추가되어 API 호출이 성공했지만 반환할 데이터가 없는 경우를 표현합니다. 이는 Result 타입 사용 시 유용하며, void와 유사한 의미를 가집니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 107,
"code_snippet": " case voteSubmitted(battleId: Int, voteMode: VoteMode, result: PreVoteResult)",
"body": "🔵 [P4] Readability\n\nvoteSubmitted 델리게이트 액션에 voteMode 파라미터를 추가하여, 투표 완료 후 상위 Coordinator가 사전 투표와 최종 투표를 구분하여 후속 처리를 할 수 있도록 했습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 109,
"code_snippet": " case alreadyFinalVoted(battleId: Int)",
"body": "🔵 [P4] Readability\n\nalreadyFinalVoted 델리게이트 액션이 추가되어, 이미 최종 투표를 완료한 배틀에 재진입했을 때 댓글 화면으로 바로 이동하도록 지시합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 120,
"code_snippet": " @dependency(\.battleUseCase) private var battleUseCase",
"body": "🟠 [P2] Major\n\nbattleRepository 대신 battleUseCase를 의존성으로 주입하도록 변경했습니다. 또한 perspectiveUseCase를 추가하여 Clean Architecture 원칙을 준수하고 있습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 146,
"code_snippet": " .ifLet(\.$customAlert, action: \.scope.customAlert) {",
"body": "🔵 [P4] Readability\n\n$customAlert 상태에 대한 ifLet 수정자를 사용하여, CustomConfirmAlertPresentationAction으로 관리합니다. 이는 TCA의 네비게이션 패턴을 따르며, 알럿의 생명주기와 액션 처리를 명확하게 합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 159,
"code_snippet": " var effects: [Effect] = []",
"body": "🔵 [P4] Readability\n\nonAppearbattleDetailbattlenil이고 로딩 중이 아닐 때 fetchBattleDetail을, myPerspectivenil일 때 fetchMyPerspective를 호출합니다. 이는 초기 데이터 로딩과 더불어 사용자의 참여 여부를 확인하여 "다시 투표" 알럿을 띄울지 결정하는 핵심 로직입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 173,
"code_snippet": " case let .shareTapped(snapshot):",
"body": "🔵 [P4] Readability\n\nshareTapped 액션 처리 시 ShareContent를 구성하고 prepareShare 액션을 디스패치합니다. 여기에는 배틀 상세 정보뿐만 아니라 선택된 옵션, 해시태그, 그리고 snapshot 데이터가 포함되어 풍부한 공유 콘텐츠를 생성합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 213,
"code_snippet": " switch state.voteMode {",
"body": "🔵 [P4] Readability\n\nprimaryButtonTapped 액션 처리 시 voteMode에 따라 submitPreVote 또는 submitPostVote 액션을 디스패치합니다. 이는 투표 종류에 따른 적절한 API 호출을 동적으로 선택합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 241,
"code_snippet": " case .fetchMyPerspective:",
"body": "🔵 [P4] Readability\n\nfetchMyPerspective 비동기 액션이 추가되어 myPerspective를 가져오고, 만약 관점이 이미 존재하면 alreadyWatched 알럿을 띄웁니다. 이는 중복 투표를 방지하고 사용자가 재투표를 할지 결정할 수 있는 기회를 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 252,
"code_snippet": " case let .deleteMyPerspective(perspectiveId):",
"body": "🔵 [P4] Readability\n\ndeleteMyPerspective 비동기 액션이 추가되어 기존 관점을 삭제하는 기능을 제공합니다. 이는 '다시 투표하기' 시나리오에서 기존 관점을 먼저 제거하는 데 사용됩니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 263,
"code_snippet": " case let .prepareShare(content):",
"body": "🔵 [P4] Readability\n\nprepareShare 비동기 액션은 ShareContent를 기반으로 ShareItem을 생성합니다. 여기에는 텍스트, URL, 그리고 스냅샷 또는 썸네일 이미지를 포함하여 공유 시트에 전달할 수 있는 [Any] 배열을 만듭니다. 원격 이미지 로딩도 지원하여 견고한 공유 기능을 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 296,
"code_snippet": " case let .submitPostVote(battleId, optionId):",
"body": "🔵 [P4] Readability\n\nsubmitPostVote 비동기 액션이 추가되어 최종 투표를 제출합니다. battleUseCase를 사용하여 도메인 계층과의 인터랙션을 분리했습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 307,
"code_snippet": " case let .postVoteResponse(result):",
"body": "🔵 [P4] Readability\n\npostVoteResponse 처리 시 성공하면 voteSubmitted 델리게이트 액션을 post 모드로 전송합니다. 실패 시 (예: 이미 투표 완료) alreadyFinalVoted 델리게이트 액션을 전송하여 댓글 화면으로 이동하도록 합니다. 이는 최종 투표의 단일성 제약 조건을 처리하는 올바른 흐름입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 324,
"code_snippet": " case let .myPerspectiveResponse(result):",
"body": "🔵 [P4] Readability\n\nmyPerspectiveResponse 처리 시 myPerspective 상태를 업데이트하고, 만약 기존 관점이 있다면 alreadyWatched() 알럿을 띄웁니다. 이는 사용자가 이미 참여한 배틀에 대한 추가적인 상호작용을 제어하는 중요한 로직입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift",
"line": 414,
"code_snippet": " private func handleScopeAction(",
"body": "🔵 [P4] Readability\n\ncustomAlert의 '확인' 액션 처리 시 myPerspective가 존재하면 deleteMyPerspective 비동기 액션을 디스패치합니다. 이는 "다시 투표하기" 로직의 일환으로 기존 투표 내역을 삭제하는 데 사용됩니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift",
"line": 16,
"code_snippet": " hero()",
"body": "🔵 [P4] Readability\n\nhero, contentSection, ctaButton과 같은 하위 뷰들이 private var에서 private func로 변경되었습니다. 이는 프로젝트의 SwiftUI 하위 뷰 분리 가이드라인을 따르는 올바른 변경입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nPreVoteLayout enum이 추가되어 PreVoteView에 사용되는 모든 레이아웃 관련 상수들을 한 곳에 모았습니다. 이는 매직 넘버를 제거하고 코드의 가독성, 유지보수성, 그리고 디자인 시스템의 일관성을 크게 향상시킵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 37,
"code_snippet": " primaryButton()",
"body": "🔵 [P4] Readability\n\nPreVoteLayout enum을 사용하여 padding 값과 height 값을 상수로 관리합니다. 이는 UI 레이아웃의 일관성을 유지하고 변경 용이성을 높입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 76,
"code_snippet": " .frame(height: PreVoteLayout.contentOverlapTopOffset)",
"body": "🔵 [P4] Readability\n\nPreVoteLayout enum의 상수들을 사용하여 UI 요소의 크기와 간격을 설정합니다. 또한 Spacer().frame(height: ...)를 사용하여 공간을 확보하는 방식으로 유연한 레이아웃을 구현했습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 86,
"code_snippet": " .scrollDisabled(true)",
"body": "🔵 [P4] Readability\n\n메인 ScrollView.scrollDisabled(true)를 적용했습니다. 이는 스크롤 동작을 의도적으로 비활성화하여 뷰의 특정 레이아웃 및 제스처에 집중하려는 디자인 결정으로 보입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 116,
"code_snippet": " VStack {",
"body": "🔵 [P4] Readability\n\n배경 이미지 위에 imageToContentGradient()를 사용하여 콘텐츠 영역과의 자연스러운 전환 효과를 줍니다. 이는 시각적인 디자인 품질을 향상시킵니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 127,
"code_snippet": " private func imageToContentGradient() -> some View {",
"body": "🔵 [P4] Readability\n\nimageToContentGradient 뷰는 LinearGradient를 사용하여 배경 이미지와 콘텐츠 영역 사이의 부드러운 전환을 시각적으로 구현합니다. PreVoteLayout 상수를 활용하여 높이를 명확히 정의했습니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 157,
"code_snippet": " Button {",
"body": "🔵 [P4] Readability\n\n공유 버튼 탭 시 captureCardSnapshot()을 호출하여 현재 카드 뷰의 스냅샷을 찍고 shareTapped 액션에 전달합니다. 이는 "카드 스냅샷(우선)" 디자인 요구사항을 직접적으로 구현한 것입니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 188,
"code_snippet": " .init(color: .beige50.opacity(0), location: 0),",
"body": "🔵 [P4] Readability\n\n콘텐츠 영역 배경에 적용되는 LinearGradientstops를 업데이트했습니다. 이는 배경과 콘텐츠의 시각적 결합을 개선하고, PreVoteLayout 상수들을 사용하여 일관된 간격과 패딩을 적용합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 346,
"code_snippet": " @mainactor",
"body": "🔵 [P4] Readability\n\ncaptureCardSnapshot() 메서드는 ImageRenderer를 사용하여 SwiftUI 뷰를 UIImage로 렌더링하고 pngData()로 변환합니다. @MainActor를 사용하여 UI 작업이 메인 스레드에서 실행되도록 보장합니다. 이는 공유 기능의 핵심적인 부분으로, 사용자에게 커스터마이징된 공유 이미지를 제공합니다."
},
{
"path": "Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift",
"line": 368,
"code_snippet": " store: Store(initialState: PreVoteFeature.State()) {",
"body": "🔵 [P4] Readability\n\nPreviewinitialStatePreVoteFeature.State(battle: .mock)에서 PreVoteFeature.State()로 변경되었습니다. 이는 PreVoteFeatureinit 로직과 onAppear 액션에서 데이터를 가져오는 흐름을 더 잘 반영합니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift",
"line": 76,
"code_snippet": " static var errorStrong: Color { .init(hex: "D7110C") }",
"body": "🔵 [P4] Readability\n\n새로운 디자인 토큰 errorStrong이 추가되었습니다. PR 설명에서 언급된 errorStrong 토큰 추가 및 neutral200/secondary200으로의 교체 작업을 뒷받침합니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nBottomActionSheet 뷰와 BottomActionItem 구조체가 추가되었습니다. 이 컴포넌트는 아이콘과 라벨을 가진 하단 액션 시트를 재사용 가능하게 구현하며, 댓글의 "…" 메뉴와 같은 곳에서 활용될 것입니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift",
"line": 38,
"code_snippet": " case report",
"body": "🔵 [P4] Readability\n\nCustomAlertStyle enum에 report, alreadyWatched, deleteConfirm 새로운 스타일이 추가되었습니다. 이는 다양한 종류의 사용자 확인 및 알림 팝업을 지원합니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift",
"line": 85,
"code_snippet": " static func deletePerspective() -> CustomAlertState {",
"body": "🔵 [P4] Readability\n\ndeletePerspective(), deleteComment(), report(), alreadyWatched()와 같은 CustomAlertState 팩토리 메서드들이 추가되었습니다. 이들은 각기 다른 스타일과 메시지를 가진 알럿을 쉽게 생성할 수 있도록 하여 사용 편의성을 높입니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 48,
"code_snippet": " .padding(.horizontal, popupHorizontalPadding)",
"body": "🔵 [P4] Readability\n\npopupHorizontalPadding을 사용하여 팝업 스타일(confirmation, finalVote, report, alreadyWatched, deleteConfirm)에 따라 수평 패딩을 동적으로 조절합니다. 이는 팝업 디자인의 일관성과 유연성을 확보하는 데 기여합니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 59,
"code_snippet": " private var popupHorizontalPadding: CGFloat {",
"body": "🔵 [P4] Readability\n\npopupHorizontalPadding computed property는 CustomAlertStyle에 따라 적절한 수평 패딩 값을 반환합니다. 이는 다양한 팝업 유형에 대한 디자인 일관성을 유지하는 데 필수적입니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 75,
"code_snippet": " case .report:",
"body": "🔵 [P4] Readability\n\npopupContent 뷰 빌더에 reportContent, alreadyWatchedContent, deleteConfirmContent 새로운 케이스들을 추가했습니다. 각 스타일은 고유한 UI를 렌더링합니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 84,
"code_snippet": " private var deleteConfirmContent: some View {",
"body": "🔵 [P4] Readability\n\ndeleteConfirmContent는 삭제 확인 팝업의 UI를 구현합니다. title, confirmTitle, cancelTitle을 사용하여 텍스트를 구성하고, 버튼은 하단 정렬 디자인을 따릅니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 133,
"code_snippet": " private var alreadyWatchedContent: some View {",
"body": "🔵 [P4] Readability\n\nalreadyWatchedContent는 이미 참여 완료된 배틀에 대한 알림 팝업 UI를 구현합니다. titlemessage를 표시하며, '다시'와 '취소' 버튼을 통해 사용자의 선택을 받습니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 249,
"code_snippet": " HStack(spacing: 0) {",
"body": "🔵 [P4] Readability\n\nconfirmationContent의 버튼 HStackspacing: 0을 적용했습니다. 이는 버튼 디자인의 간격을 조절하는 미세한 개선입니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 273,
"code_snippet": " .padding(.top, 20)",
"body": "🔵 [P4] Readability\n\nconfirmationContentpadding(.top, 20)을 추가하여 콘텐츠의 상단 여백을 조절했습니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 284,
"code_snippet": " @State private var selectedReason: ReportReason?",
"body": "🔵 [P4] Readability\n\nreportContent는 신고 팝업의 UI를 구현합니다. @State selectedReason을 사용하여 선택된 신고 사유를 관리하며, reasonGrid, reportButtons와 같은 하위 뷰로 구성됩니다. 신고 사유 선택을 위한 라디오 버튼과 하단 정렬 버튼이 잘 구현되어 있습니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift",
"line": 406,
"code_snippet": "public enum ReportReason: String, CaseIterable, Identifiable, Equatable {",
"body": "🔵 [P4] Readability\n\nReportReason enum은 신고 사유 목록을 정의합니다. CaseIterable, Identifiable, Equatable 프로토콜을 준수하며, title computed property를 통해 사용자에게 표시될 문자열을 제공합니다. 이는 신고 기능의 데이터 모델을 깔끔하게 정의합니다."
},
{
"path": "Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift",
"line": 1,
"code_snippet": "//",
"body": "🔵 [P4] Readability\n\nFloatingErrorView는 채팅방 오디오 오류 메시지를 표시하는 플로팅 배너 UI 컴포넌트입니다. errorStrong 디자인 토큰을 사용하여 디자인 가이드라인을 따르며, 경고 아이콘과 메시지로 구성됩니다. 애니메이션과 함께 상단에 부드럽게 나타나는 형태로 사용자에게 오류를 효과적으로 알립니다."
}
]
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

전반적으로 잘 구성된 PR입니다. 댓글/대댓글 CRUD, 큐레이팅 화면, 채팅방 오디오 오류 배너 기능이 잘 구현되었으며, Clean Architecture, TCA, SwiftUI, Swift 코드 품질 가이드라인을 잘 준수하고 있습니다. 특히 새로운 도메인 및 DTO 매핑, 에러 처리, UI 컴포넌트 분리 등 전반적인 아키텍처 관점에서 모범적인 패턴을 보여줍니다.

주요 개선점은 다음과 같습니다:

  • ChatCoordinator에서 CommentReplyFeature로 진입 시 perspectiveIdnil일 경우 0으로 강제 할당하는 부분에 대한 명확한 예외 처리가 필요합니다.

state.routes.push(
.commentReply(
.init(
perspectiveId: comment.perspectiveId ?? 0,

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] Major

comment.perspectiveIdnil일 경우 0으로 대체하고 있습니다. CommentReplyFeatureperspectiveIdInt 타입이고, 대댓글 목록을 가져오는 핵심 식별자이므로 0이 유효한 ID가 아닐 경우 문제가 발생할 수 있습니다.

comment.perspectiveIdnil이 될 수 없는 상황이라면 guard let으로 미리 바인딩하는 것이 명확하며, nil이 될 수 있다면 해당 경우에 대한 명확한 에러 처리 (예: 경고 표시 후 뒤로가기) 또는 CommentReplyFeature 진입 불가를 고려해야 합니다.

Suggested change
perspectiveId: comment.perspectiveId ?? 0,
guard let perspectiveId = comment.perspectiveId else {
// perspectiveId가 없는 CommentItem은 대댓글 화면으로 진입할 수 없다고 가정하거나
// 사용자에게 알림 후 early return 처리 필요
Log.error("[ChatCoordinator] Attempted to open CommentReply for a CommentItem with nil perspectiveId")
return .none
}
state.routes.push(
.commentReply(
.init(
perspectiveId: perspectiveId,
parentComment: comment
)
)
)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fdb84a9e9b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +148 to +150
} catch {
Log.debug("[BattleRepositoryImpl] fetchMyPerspective failed (no participation): \(error.localizedDescription)")
return nil

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 Propagate non-absence errors when fetching my perspective

When this request fails because the device is offline, the token is expired, the server is unavailable, or decoding fails, the catch block converts the error into nil as though the user has no perspective. This lets the voting flow proceed with incorrect participation state, and after a successful createPerspective POST the follow-up failure is also reported as a failed creation even though the comment was already published. Only the specific response meaning “no perspective exists” should return nil; other failures should be thrown.

Useful? React with 👍 / 👎.

Comment on lines +288 to +292
state.isSubmitting = true
state.commentText = ""
if let editId = state.editingPerspectiveId {
state.editingPerspectiveId = nil
return .send(.async(.updatePerspective(perspectiveId: editId, content: text)))

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 Preserve the comment draft until submission succeeds

When creating a comment fails due to connectivity, authentication, or a server error, the text is cleared before the request and createCommentResponse(.failure) only logs the error, permanently discarding the user's draft. Editing has the same problem because the edit state and text are cleared before an update whose errors are suppressed. Keep the submitted text/edit context until success, or restore it on failure so the user can retry.

Useful? React with 👍 / 👎.

Comment on lines +367 to +370
let cursor = reset ? nil : state.nextCursor
return .run { [repository = perspectiveUseCase] send in
let result = await Result {
try await repository.fetchLabeledComments(perspectiveId: pid, cursor: cursor, size: 20)

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 Add a path to load subsequent reply pages

The reply request is explicitly capped at 20 items, but every call site dispatches fetchReplies(reset: true) and no view action ever requests reset: false. Although the reducer stores nextCursor and hasNext, perspectives with more than 20 replies permanently hide every later reply; trigger a non-reset fetch near the end of the visible list.

Useful? React with 👍 / 👎.

Comment on lines +133 to +134
case let .success(page):
state.battles = page.items

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 Retain recommendation pagination metadata

When the recommendations endpoint returns hasNext == true, this branch keeps only the first page's items and discards both nextCursor and hasNext; the service/interface also expose no cursor parameter or load-more action. As a result, the curation screen can never display recommendations beyond the server's first page. Store the pagination metadata and request subsequent pages as the user scrolls.

Useful? React with 👍 / 👎.

Comment on lines +234 to +236
let wasLiked = state.comments[index].isLiked
state.comments[index].isLiked.toggle()
state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1

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 Roll back optimistic like changes after request failures

When liking or unliking a perspective fails because of connectivity, authorization, or a backend error, these optimistic mutations remain in state because likeResponse(.failure) only logs the error. The UI therefore shows a like state and count that were never recorded and stays incorrect until the list is reloaded. Restore the previous values on failure or defer the mutation until the request succeeds.

Useful? React with 👍 / 👎.

@Roy-wonji Roy-wonji merged commit 94a92d3 into develop Jun 4, 2026
4 checks passed
@Roy-wonji Roy-wonji deleted the feature/Comments branch June 4, 2026 06:13
This was referenced Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ 기능추가 새로운 기능 추가 🎨 디자인 UI 디자인 작업 🐛 버그 버그 수정

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant