From c0369e6f58db1afd9c723e46ffd413c56ed3e8d9 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 8 Jul 2026 01:56:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 198 ++++++--- SodaLive/Sources/App/AppStep.swift | 8 +- SodaLive/Sources/ContentView.swift | 11 +- SodaLive/Sources/I18n/I18n.swift | 1 + .../CreatorChannelCommunityGridItem.swift | 38 +- .../CreatorChannelCommunityListItem.swift | 56 +-- .../CreatorChannelCommunityTabView.swift | 10 +- ...elCommunityPostDetailCommentListView.swift | 51 +++ ...ChannelCommunityPostDetailCommentRow.swift | 134 ++++++ ...hannelCommunityPostDetailContentView.swift | 194 +++++++++ ...orChannelCommunityPostDetailInputBar.swift | 152 +++++++ ...reatorChannelCommunityPostDetailView.swift | 116 ++++++ ...rChannelCommunityPostDetailViewModel.swift | 383 ++++++++++++++++++ ...orChannelCommunityPostDetailResponse.swift | 70 ++++ .../CreatorChannelCommunityApi.swift | 22 +- .../CreatorChannelCommunityRepository.swift | 12 + .../CreatorChannel/CreatorChannelView.swift | 14 +- .../CreatorChannelReplyDetailView.swift | 3 +- .../CreatorChannelReplyDetailViewModel.swift | 147 +++++-- .../CreatorChannelReplyDetailContext.swift | 5 + ...CreatorChannelReplyDetailDisplayItem.swift | 32 ++ .../plan-task.md | 379 +++++++++++++++++ docs/20260707_커뮤니티_게시글_상세/prd.md | 289 +++++++++++++ 23 files changed, 2187 insertions(+), 138 deletions(-) create mode 100644 SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift create mode 100644 docs/20260707_커뮤니티_게시글_상세/plan-task.md create mode 100644 docs/20260707_커뮤니티_게시글_상세/prd.md diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index c2516e76..69cfd9a3 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,30 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - CC0710012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; }; - CC0710022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; }; - CC0710032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; }; - CC0710042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; }; - CC0710052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; }; - CC0710062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; }; - CC0710072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; }; - CC0710082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; }; - CC0720012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; }; - CC0720022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; }; - CC0720032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; }; - CC0720042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; }; - CC0720052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; }; - CC0720062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; }; - CC0720072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; }; - CC0720082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; }; - CC0730012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; }; - CC0730022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; }; - CC0730032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; }; - CC0730042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; }; - CC0730052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; }; - CC0730062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; }; - CC0730072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; }; - CC0730082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; }; 00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; }; 042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; }; 0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; }; @@ -2043,6 +2019,51 @@ CC0630082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; }; CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; }; CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; }; + CC0710012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; }; + CC0710022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; }; + CC0710032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; }; + CC0710042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; }; + CC0710052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; }; + CC0710062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; }; + CC0710072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; }; + CC0710082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; }; + CC0720012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; }; + CC0720022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; }; + CC0720032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; }; + CC0720042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; }; + CC0720052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; }; + CC0720062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; }; + CC0720072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; }; + CC0720082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; }; + CC0730012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; }; + CC0730022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; }; + CC0730032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; }; + CC0730042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; }; + CC0730052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; }; + CC0730062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; }; + CC0730072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; }; + CC0730082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; }; + CC0810012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */; }; + CC0810022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */; }; + CC0810032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */; }; + CC0810042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */; }; + CC0810052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */; }; + CC0810062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */; }; + CC0810072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */; }; + CC0820012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */; }; + CC0820022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */; }; + CC0820032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */; }; + CC0820042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */; }; + CC0820052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */; }; + CC0820062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */; }; + CC0820072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */; }; + CC0830012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */; }; + CC0830022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */; }; + CC0830032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */; }; + CC0830042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */; }; + CC0830052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */; }; + CC0830062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */; }; + CC0830072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */; }; CD256952CC366FFB67557807 /* CreatorChannelFanTalkApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */; }; CE935959F7A2BF7301F517DB /* CreatorChannelFanTalkTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1C1B0C73E8D92112E47610 /* CreatorChannelFanTalkTabResponse.swift */; }; CE93595AF7A2BF7301F517DB /* CreatorChannelFanTalkWriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C5D06F4F54C0ED7B7BD462 /* CreatorChannelFanTalkWriteViewModel.swift */; }; @@ -2073,14 +2094,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailView.swift; sourceTree = ""; }; - CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailViewModel.swift; sourceTree = ""; }; - CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailContext.swift; sourceTree = ""; }; - CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailDisplayItem.swift; sourceTree = ""; }; - CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailTitleBar.swift; sourceTree = ""; }; - CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailFeedView.swift; sourceTree = ""; }; - CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailInputBar.swift; sourceTree = ""; }; - CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailActionPopup.swift; sourceTree = ""; }; 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkApi.swift; sourceTree = ""; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = ""; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; @@ -3006,6 +3019,21 @@ CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityGridItem.swift; sourceTree = ""; }; CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityUploadButton.swift; sourceTree = ""; }; CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityEmptyView.swift; sourceTree = ""; }; + CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailView.swift; sourceTree = ""; }; + CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailViewModel.swift; sourceTree = ""; }; + CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailContext.swift; sourceTree = ""; }; + CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailDisplayItem.swift; sourceTree = ""; }; + CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailTitleBar.swift; sourceTree = ""; }; + CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailFeedView.swift; sourceTree = ""; }; + CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailInputBar.swift; sourceTree = ""; }; + CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailActionPopup.swift; sourceTree = ""; }; + CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailResponse.swift; sourceTree = ""; }; + CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailView.swift; sourceTree = ""; }; + CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailViewModel.swift; sourceTree = ""; }; + CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailContentView.swift; sourceTree = ""; }; + CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailCommentListView.swift; sourceTree = ""; }; + CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailCommentRow.swift; sourceTree = ""; }; + CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailInputBar.swift; sourceTree = ""; }; CC73FFC54CB1D988053059D1 /* LanguageEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageEnvironment.swift; sourceTree = ""; }; CE82912F2F111593BE42E33C /* CommunityPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommunityPostCard.swift; sourceTree = ""; }; D03CBA8005C81F7C31F59A89 /* Pods-SodaLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive.release.xcconfig"; path = "Target Support Files/Pods-SodaLive/Pods-SodaLive.release.xcconfig"; sourceTree = ""; }; @@ -3097,37 +3125,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - CC0700092FF0700000A5BBA3 /* Models */ = { - isa = PBXGroup; - children = ( - CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */, - CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */, - ); - path = Models; - sourceTree = ""; - }; - CC07000A2FF0700000A5BBA3 /* Components */ = { - isa = PBXGroup; - children = ( - CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */, - CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */, - CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */, - CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */, - ); - path = Components; - sourceTree = ""; - }; - CC07000B2FF0700000A5BBA3 /* ReplyDetail */ = { - isa = PBXGroup; - children = ( - CC0700092FF0700000A5BBA3 /* Models */, - CC07000A2FF0700000A5BBA3 /* Components */, - CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */, - CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */, - ); - path = ReplyDetail; - sourceTree = ""; - }; 038B15E425DE0639156ADBA9 /* Repository */ = { isa = PBXGroup; children = ( @@ -6068,6 +6065,7 @@ isa = PBXGroup; children = ( CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */, + CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */, ); path = Models; sourceTree = ""; @@ -6099,12 +6097,65 @@ CC06000B2FF0600000A5BBA3 /* Models */, CC06000C2FF0600000A5BBA3 /* Repository */, CC06000D2FF0600000A5BBA3 /* Components */, + CC0800092FF0800000A5BBA3 /* Detail */, CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */, CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */, ); path = Community; sourceTree = ""; }; + CC0800082FF0800000A5BBA3 /* Components */ = { + isa = PBXGroup; + children = ( + CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */, + CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */, + CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */, + CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */, + ); + path = Components; + sourceTree = ""; + }; + CC0800092FF0800000A5BBA3 /* Detail */ = { + isa = PBXGroup; + children = ( + CC0800082FF0800000A5BBA3 /* Components */, + CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */, + CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */, + ); + path = Detail; + sourceTree = ""; + }; + CC0700092FF0700000A5BBA3 /* Models */ = { + isa = PBXGroup; + children = ( + CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */, + CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */, + ); + path = Models; + sourceTree = ""; + }; + CC07000A2FF0700000A5BBA3 /* Components */ = { + isa = PBXGroup; + children = ( + CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */, + CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */, + CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */, + CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */, + ); + path = Components; + sourceTree = ""; + }; + CC07000B2FF0700000A5BBA3 /* ReplyDetail */ = { + isa = PBXGroup; + children = ( + CC0700092FF0700000A5BBA3 /* Models */, + CC07000A2FF0700000A5BBA3 /* Components */, + CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */, + CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */, + ); + path = ReplyDetail; + sourceTree = ""; + }; D37878F5929361B43B1E3C5C /* Card */ = { isa = PBXGroup; children = ( @@ -6556,6 +6607,13 @@ CC0710062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */, CC0710072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */, CC0710082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */, + CC0810012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */, + CC0810022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */, + CC0810032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */, + CC0810042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */, + CC0810052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */, + CC0810062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */, + CC0810072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */, CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, @@ -7459,6 +7517,13 @@ CC0720062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */, CC0720072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */, CC0720082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */, + CC0820012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */, + CC0820022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */, + CC0820032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */, + CC0820042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */, + CC0820052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */, + CC0820062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */, + CC0820072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */, CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, @@ -8390,6 +8455,13 @@ CC0730062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */, CC0730072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */, CC0730082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */, + CC0830012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */, + CC0830022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */, + CC0830032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */, + CC0830042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */, + CC0830052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */, + CC0830062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */, + CC0830072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */, CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */, CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */, CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */, diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 822308c6..ea03dd84 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -138,7 +138,13 @@ enum AppStep { case creatorChannelReplyDetail( context: CreatorChannelReplyDetailContext, - onFanTalkRefresh: (() -> Void)? = nil + onFanTalkRefresh: (() -> Void)? = nil, + onCommunityRefresh: (() -> Void)? = nil + ) + + case creatorChannelCommunityPostDetail( + postId: Int, + onCommunityRefresh: (() -> Void)? = nil ) case canCoupon(refresh: () -> Void) diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index d9027df8..21430f5c 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -257,8 +257,15 @@ struct AppStepLayerView: View { case .creatorChannelFanTalkWrite(let creatorId, let mode, _, _, let onSuccess): CreatorChannelFanTalkWriteView(creatorId: creatorId, mode: mode, onSuccess: onSuccess) - case .creatorChannelReplyDetail(let context, let onFanTalkRefresh): - CreatorChannelReplyDetailView(context: context, onFanTalkRefresh: onFanTalkRefresh) + case .creatorChannelReplyDetail(let context, let onFanTalkRefresh, let onCommunityRefresh): + CreatorChannelReplyDetailView( + context: context, + onFanTalkRefresh: onFanTalkRefresh, + onCommunityRefresh: onCommunityRefresh + ) + + case .creatorChannelCommunityPostDetail(let postId, let onCommunityRefresh): + CreatorChannelCommunityPostDetailView(postId: postId, onCommunityRefresh: onCommunityRefresh) case .canCoupon(let refresh): CanCouponView(refresh: refresh) diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 363bd0e9..a5d536a6 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3422,6 +3422,7 @@ If you block this user, the following features will be restricted. static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") } static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") } + static var commentPlaceholder: String { pick(ko: "댓글을 입력하세요", en: "Enter a comment", ja: "コメントを入力してください") } static var communityEmpty: String { pick( ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!", diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift index b02016ec..d45d4df1 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift @@ -4,6 +4,7 @@ struct CreatorChannelCommunityGridItem: View { let post: CreatorChannelCommunityPostItem let isOwnPost: Bool let onTapPurchase: () -> Void + let onTapDetail: () -> Void var body: some View { GeometryReader { proxy in @@ -14,20 +15,10 @@ struct CreatorChannelCommunityGridItem: View { .overlay(paidPriceOverlay) } .buttonStyle(.plain) - } else if let imageUrl = post.imageUrl, !imageUrl.isEmpty { - DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width)) - .frame(width: proxy.size.width, height: proxy.size.width) - .aspectRatio(1, contentMode: .fill) - .clipped() } else { - Color.gray900 - - Text(fallbackText) - .appFont(size: 12, weight: .medium) - .foregroundColor(.white) - .multilineTextAlignment(.center) - .lineLimit(3) - .padding(SodaSpacing.s8) + tileContent(proxy: proxy) + .contentShape(Rectangle()) + .onTapGesture(perform: onTapDetail) } } .frame(width: proxy.size.width, height: proxy.size.width) @@ -47,6 +38,27 @@ struct CreatorChannelCommunityGridItem: View { .aspectRatio(1, contentMode: .fit) } + @ViewBuilder + private func tileContent(proxy: GeometryProxy) -> some View { + if let imageUrl = post.imageUrl, !imageUrl.isEmpty { + DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width)) + .frame(width: proxy.size.width, height: proxy.size.width) + .aspectRatio(1, contentMode: .fill) + .clipped() + } else { + ZStack { + Color.gray900 + + Text(fallbackText) + .appFont(size: 12, weight: .medium) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(SodaSpacing.s8) + } + } + } + private var paidPriceOverlay: some View { VStack(spacing: SodaSpacing.s4) { Image("ic_new_community_lock") diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift index 0b6e59c2..a04aed67 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift @@ -8,9 +8,9 @@ struct CreatorChannelCommunityListItem: View { let isOwnPost: Bool let isOwnCreatorChannel: Bool let onTapLike: () -> Void - let onTapComment: () -> Void let onTapMore: () -> Void let onTapPurchase: () -> Void + let onTapDetail: () -> Void @State private var localIsLike: Bool @State private var localLikeCount: Int @@ -22,17 +22,17 @@ struct CreatorChannelCommunityListItem: View { isOwnPost: Bool, isOwnCreatorChannel: Bool, onTapLike: @escaping () -> Void, - onTapComment: @escaping () -> Void, onTapMore: @escaping () -> Void, - onTapPurchase: @escaping () -> Void + onTapPurchase: @escaping () -> Void, + onTapDetail: @escaping () -> Void ) { self.post = post self.isOwnPost = isOwnPost self.isOwnCreatorChannel = isOwnCreatorChannel self.onTapLike = onTapLike - self.onTapComment = onTapComment self.onTapMore = onTapMore self.onTapPurchase = onTapPurchase + self.onTapDetail = onTapDetail _localIsLike = State(initialValue: post.isLiked ?? false) _localLikeCount = State(initialValue: post.likeCount) } @@ -44,6 +44,7 @@ struct CreatorChannelCommunityListItem: View { } header + contentText if isPaidLocked { @@ -58,6 +59,8 @@ struct CreatorChannelCommunityListItem: View { .padding(SodaSpacing.s14) .background(Color.gray900) .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .onTapGesture(perform: onTapDetail) .onChange(of: post.isLiked) { newValue in localIsLike = newValue ?? false } @@ -81,21 +84,23 @@ struct CreatorChannelCommunityListItem: View { private var header: some View { HStack(spacing: SodaSpacing.s8) { - DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42)) - .background(Color.gray800) - .clipShape(Circle()) - .frame(width: 42, height: 42) + HStack(spacing: SodaSpacing.s8) { + DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42)) + .background(Color.gray800) + .clipShape(Circle()) + .frame(width: 42, height: 42) - VStack(alignment: .leading, spacing: 2) { - Text(post.creatorNickname) - .appFont(size: 14, weight: .medium) - .foregroundColor(.white) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(post.creatorNickname) + .appFont(size: 14, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) - Text(post.relativeTimeText()) - .appFont(size: 14, weight: .regular) - .foregroundColor(Color.gray500) - .lineLimit(1) + Text(post.relativeTimeText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + } } Spacer(minLength: 0) @@ -235,18 +240,15 @@ struct CreatorChannelCommunityListItem: View { } if post.isCommentAvailable && !isPaidLocked { - Button(action: onTapComment) { - HStack(spacing: SodaSpacing.s4) { - Image("ic_feed_community_reply") - .resizable() - .frame(width: 18, height: 18) + HStack(spacing: SodaSpacing.s4) { + Image("ic_feed_community_reply") + .resizable() + .frame(width: 18, height: 18) - Text("\(post.commentCount)") - .appFont(size: 16, weight: .regular) - .foregroundColor(Color.gray400) - } + Text("\(post.commentCount)") + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray400) } - .buttonStyle(.plain) } } .frame(height: 24) diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift index d73a5730..6a3a2682 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift @@ -4,6 +4,7 @@ struct CreatorChannelCommunityTabView: View { let creatorId: Int let isOwnCreatorChannel: Bool @ObservedObject var viewModel: CreatorChannelCommunityViewModel + let onTapDetail: (CreatorChannelCommunityPostItem) -> Void private let gridColumns = [ GridItem(.flexible(), spacing: 0), @@ -65,14 +66,14 @@ struct CreatorChannelCommunityTabView: View { onTapLike: { viewModel.communityPostLike(postId: post.postId) }, - onTapComment: { - viewModel.openCommentList(post: post) - }, onTapMore: { viewModel.openReportMenu(post: post) }, onTapPurchase: { viewModel.openPurchaseDialog(post: post) + }, + onTapDetail: { + onTapDetail(post) } ) .onAppear { @@ -96,6 +97,9 @@ struct CreatorChannelCommunityTabView: View { isOwnPost: isOwnPost, onTapPurchase: { viewModel.openPurchaseDialog(post: post) + }, + onTapDetail: { + onTapDetail(post) } ) .onAppear { diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift new file mode 100644 index 00000000..4fe0a876 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct CreatorChannelCommunityPostDetailCommentListView: View { + let creatorId: Int + let postId: Int + let commentCount: Int + let comments: [CreatorChannelCommunityCommentResponse] + let selectedComment: CreatorChannelCommunityCommentResponse? + let canEdit: (CreatorChannelCommunityCommentResponse) -> Bool + let canDelete: (CreatorChannelCommunityCommentResponse) -> Bool + let onAppearComment: (CreatorChannelCommunityCommentResponse) -> Void + let onTapReplyDetail: (CreatorChannelCommunityCommentResponse) -> Void + let onTapMore: (CreatorChannelCommunityCommentResponse) -> Void + let onTapEdit: (CreatorChannelCommunityCommentResponse) -> Void + let onTapDelete: (CreatorChannelCommunityCommentResponse) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s16) { + Text("댓글 \(commentCount)") + .appFont(size: 16, weight: .medium) + .foregroundColor(.white) + .padding(.horizontal, SodaSpacing.s14) + + LazyVStack(alignment: .leading, spacing: SodaSpacing.s20) { + ForEach(comments, id: \.commentId) { comment in + ZStack(alignment: .topTrailing) { + CreatorChannelCommunityPostDetailCommentRow( + comment: comment, + creatorId: creatorId, + onTapReplyDetail: { onTapReplyDetail(comment) }, + onTapMore: { onTapMore(comment) } + ) + + if selectedComment?.commentId == comment.commentId { + CreatorChannelActionPopup( + canEdit: canEdit(comment), + canDelete: canDelete(comment), + onTapEdit: { onTapEdit(comment) }, + onTapDelete: { onTapDelete(comment) } + ) + .padding(.top, 28) + } + } + .onAppear { onAppearComment(comment) } + } + } + .padding(.horizontal, SodaSpacing.s14) + } + .padding(.vertical, SodaSpacing.s20) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift new file mode 100644 index 00000000..da702d39 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct CreatorChannelCommunityPostDetailCommentRow: View { + let comment: CreatorChannelCommunityCommentResponse + let creatorId: Int + let onTapReplyDetail: () -> Void + let onTapMore: () -> Void + + private var currentUserId: Int { UserDefaults.int(forKey: .userId) } + + var body: some View { + Button(action: onTapReplyDetail) { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + HStack(alignment: .top, spacing: SodaSpacing.s8) { + DownsampledKFImage(url: URL(string: comment.writerProfileImageUrl), size: CGSize(width: 42, height: 42)) + .background(Color.gray800) + .clipShape(Circle()) + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + header + + Text(comment.content) + .appFont(size: 16, weight: .regular) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + if let latestReply = comment.latestReply { + latestReplyCard(latestReply) + .padding(.leading, 50) + .background(alignment: .leading) { + CreatorChannelCommunityPostDetailReplyConnector() + .stroke(Color.gray800, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .frame(width: 42) + .padding(.leading, 20) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var header: some View { + HStack(spacing: SodaSpacing.s8) { + Text(comment.writerNickname) + .appFont(size: 14, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + + Text(comment.relativeTimeText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + + if comment.isSecret { + Text(I18n.Explorer.secretComment) + .appFont(size: 11, weight: .medium) + .foregroundColor(Color.grayee) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.soda400.opacity(0.2)) + .cornerRadius(3) + } + + Spacer(minLength: 0) + + if showsMoreButton { + Button(action: onTapMore) { + Image("ic_new_more") + .resizable() + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + } + } + + private func latestReplyCard(_ latestReply: CreatorChannelCommunityReplyResponse) -> some View { + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + HStack(spacing: SodaSpacing.s8) { + DownsampledKFImage(url: URL(string: latestReply.writerProfileImageUrl), size: CGSize(width: 20, height: 20)) + .background(Color.gray800) + .clipShape(Circle()) + .frame(width: 20, height: 20) + + Text(latestReply.writerNickname) + .appFont(size: 13, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + + Text(latestReply.relativeTimeText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + + Spacer(minLength: 0) + } + + Text(latestReply.content) + .appFont(size: 16, weight: .regular) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(SodaSpacing.s12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + private var showsMoreButton: Bool { + comment.writerId == currentUserId || creatorId == currentUserId + } +} + +private struct CreatorChannelCommunityPostDetailReplyConnector: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let startX: CGFloat = 0 + let endX: CGFloat = rect.width + let endY = rect.height - 8 + let bendY = max(0, endY - 14) + + path.move(to: CGPoint(x: startX, y: 0)) + path.addLine(to: CGPoint(x: startX, y: bendY)) + path.addQuadCurve(to: CGPoint(x: endX, y: endY), control: CGPoint(x: startX, y: endY)) + return path + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift new file mode 100644 index 00000000..0a2277f3 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift @@ -0,0 +1,194 @@ +import SwiftUI + +import SDWebImageSwiftUI + +struct CreatorChannelCommunityPostDetailContentView: View { + let detail: CreatorChannelCommunityPostDetailResponse + let onTapLike: () -> Void + let onTapPurchase: () -> Void + + @StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared + @StateObject private var contentPlayManager = ContentPlayManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s16) { + if detail.isPinned { + pinnedLabel + } + + header + + Text(detail.content) + .appFont(size: 16, weight: .regular) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + mediaContent + reactionBar + } + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black) + } + + private var pinnedLabel: some View { + HStack(spacing: 2) { + Image("ic_pin") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + + Text("Notice") + .appFont(size: 14, weight: .medium) + .foregroundColor(Color(hex: "73FF01")) + } + } + + private var header: some View { + HStack(spacing: SodaSpacing.s8) { + DownsampledKFImage(url: URL(string: detail.creatorProfileUrl), size: CGSize(width: 42, height: 42)) + .background(Color.gray800) + .clipShape(Circle()) + .frame(width: 42, height: 42) + + VStack(alignment: .leading, spacing: 2) { + Text(detail.creatorNickname) + .appFont(size: 14, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + + Text(detail.relativeTimeText()) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if detail.price > 0 && detail.existOrdered && isOwnPost == false { + purchaseCompleteTag + } + } + } + + @ViewBuilder + private var mediaContent: some View { + if isPaidLocked { + Button(action: onTapPurchase) { + RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous) + .fill(Color.gray800) + .frame(maxWidth: .infinity) + .frame(height: 236) + .overlay(paidLockedOverlay) + } + .buttonStyle(.plain) + } else if let imageUrl = detail.imageUrl, imageUrl.isEmpty == false { + ZStack { + WebImage(url: URL(string: imageUrl)) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .background(Color.gray800) + + audioPlayButton + } + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } else if detail.audioUrl?.isEmpty == false { + RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous) + .fill(Color.gray900) + .frame(maxWidth: .infinity) + .frame(height: 132) + .overlay(audioPlayButton) + } + } + + @ViewBuilder + private var audioPlayButton: some View { + if let audioUrl = detail.audioUrl, audioUrl.isEmpty == false { + Button { + contentPlayManager.pauseAudio() + playManager.toggleContent(item: CreatorCommunityContentItem(contentId: detail.postId, url: audioUrl)) + } label: { + Image(playManager.isPlaying && playManager.currentPlayingContentId == detail.postId ? "btn_audio_content_pause" : "btn_audio_content_play") + .resizable() + .frame(width: 48, height: 48) + } + .buttonStyle(.plain) + } + } + + private var paidLockedOverlay: some View { + VStack(spacing: SodaSpacing.s4) { + Image("ic_new_community_lock") + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .frame(width: 24, height: 24) + + HStack(spacing: SodaSpacing.s6) { + Image("ic_bar_cash") + .resizable() + .frame(width: 20, height: 20) + + Text("\(detail.price)") + .appFont(size: 16, weight: .medium) + .foregroundColor(Color.black) + } + .padding(SodaSpacing.s8) + .background(Color.white) + .clipShape(Capsule()) + } + } + + private var purchaseCompleteTag: some View { + Text("구매완료") + .appFont(size: 12, weight: .medium) + .foregroundColor(Color.black) + .padding(.horizontal, SodaSpacing.s8) + .padding(.vertical, SodaSpacing.s4) + .background(Color.soda400) + .clipShape(Capsule()) + } + + private var reactionBar: some View { + HStack(spacing: 15) { + if isPaidLocked == false { + Button(action: onTapLike) { + HStack(spacing: SodaSpacing.s4) { + Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart") + .resizable() + .frame(width: 18, height: 18) + + Text("\(detail.likeCount)") + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray500) + } + } + .buttonStyle(.plain) + + if detail.isCommentAvailable { + HStack(spacing: SodaSpacing.s4) { + Image("ic_feed_community_reply") + .resizable() + .frame(width: 18, height: 18) + + Text("\(detail.commentCount)") + .appFont(size: 16, weight: .regular) + .foregroundColor(Color.gray500) + } + } + } + } + .frame(height: 24) + } + + private var isOwnPost: Bool { + detail.creatorId == UserDefaults.int(forKey: .userId) + } + + private var isPaidLocked: Bool { + detail.price > 0 && detail.existOrdered == false && isOwnPost == false + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift new file mode 100644 index 00000000..ae24a732 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct CreatorChannelCommunityPostDetailInputBar: View { + @Binding var text: String + @Binding var isSecret: Bool + let isShowSecret: Bool + let isSendEnabled: Bool + let isEditing: Bool + let onTapSend: () -> Void + let onTapCancelEditing: () -> Void + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.gray800) + .frame(height: 1) + + if isShowSecret { + secretToggle + } + + HStack(alignment: .bottom, spacing: SodaSpacing.s8) { + if isEditing { + Button(action: onTapCancelEditing) { + Image("ic_close_white") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + } + + TextField(I18n.CreatorChannelCommunity.commentPlaceholder, text: $text, axis: .vertical) + .autocapitalization(.none) + .disableAutocorrection(true) + .appFont(size: 15, weight: .regular) + .foregroundColor(.white) + .accentColor(Color.soda400) + .lineLimit(1...3) + .padding(.horizontal, SodaSpacing.s14) + .padding(.vertical, SodaSpacing.s8) + .frame(minHeight: 40) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + + Button(action: { + guard isSendEnabled else { return } + onTapSend() + }) { + Image(isSendEnabled ? "ic_arrow_up_white" : "ic_arrow_up_gray") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .frame(width: 36, height: 36) + .background(isSendEnabled ? Color.soda400 : Color.gray900) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(isSendEnabled == false) + } + .padding(.horizontal, SodaSpacing.s14) + .padding(.vertical, SodaSpacing.s8) + } + .background(Color.black) + } + + private var secretToggle: some View { + Button { + isSecret.toggle() + } label: { + HStack(spacing: SodaSpacing.s6) { + Image(isSecret ? "btn_square_select_checked" : "btn_square_select_normal") + .resizable() + .frame(width: 18, height: 18) + + Text(I18n.Explorer.secretComment) + .appFont(size: 13, weight: .regular) + .foregroundColor(Color.gray500) + + Spacer(minLength: 0) + } + .padding(.horizontal, SodaSpacing.s14) + .padding(.top, SodaSpacing.s8) + } + .buttonStyle(.plain) + } +} + +struct CreatorChannelCommunityPostDetailInputBar_Previews: PreviewProvider { + static var previews: some View { + Group { + PreviewWrapper( + text: "Secret comment", + isSecret: true, + isShowSecret: true, + isSendEnabled: true, + isEditing: false + ) + .padding(.vertical, SodaSpacing.s16) + .background(Color.black) + .previewLayout(.sizeThatFits) + .previewDisplayName("isShowSecret: true") + + PreviewWrapper( + text: "Public comment", + isSecret: false, + isShowSecret: false, + isSendEnabled: true, + isEditing: false + ) + .padding(.vertical, SodaSpacing.s16) + .background(Color.black) + .previewLayout(.sizeThatFits) + .previewDisplayName("isShowSecret: false") + } + } + + private struct PreviewWrapper: View { + @State private var text: String + @State private var isSecret: Bool + let isShowSecret: Bool + let isSendEnabled: Bool + let isEditing: Bool + + init( + text: String, + isSecret: Bool, + isShowSecret: Bool, + isSendEnabled: Bool, + isEditing: Bool + ) { + _text = State(initialValue: text) + _isSecret = State(initialValue: isSecret) + self.isShowSecret = isShowSecret + self.isSendEnabled = isSendEnabled + self.isEditing = isEditing + } + + var body: some View { + CreatorChannelCommunityPostDetailInputBar( + text: $text, + isSecret: $isSecret, + isShowSecret: isShowSecret, + isSendEnabled: isSendEnabled, + isEditing: isEditing, + onTapSend: {}, + onTapCancelEditing: {} + ) + } + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift new file mode 100644 index 00000000..141c6278 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct CreatorChannelCommunityPostDetailView: View { + let postId: Int + let onCommunityRefresh: (() -> Void)? + + @StateObject private var viewModel = CreatorChannelCommunityPostDetailViewModel() + + var body: some View { + ZStack { + VStack(spacing: 0) { + CreatorChannelReplyDetailTitleBar { + AppState.shared.back() + } + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + if let detail = viewModel.detail { + CreatorChannelCommunityPostDetailContentView( + detail: detail, + onTapLike: viewModel.toggleLike, + onTapPurchase: showPurchaseError + ) + + if detail.isCommentAvailable { + CreatorChannelCommunityPostDetailCommentListView( + creatorId: detail.creatorId, + postId: detail.postId, + commentCount: detail.commentCount, + comments: viewModel.comments, + selectedComment: viewModel.selectedComment, + canEdit: viewModel.canEdit, + canDelete: viewModel.canDelete, + onAppearComment: viewModel.fetchNextCommentsIfNeeded, + onTapReplyDetail: showReplyDetail, + onTapMore: viewModel.selectComment, + onTapEdit: viewModel.beginEditing, + onTapDelete: viewModel.presentDeleteDialog + ) + } + } + + Spacer(minLength: SodaSpacing.s20) + } + } + } + .background(Color.black.ignoresSafeArea()) + + if viewModel.isLoading && viewModel.hasLoaded == false { + LoadingView() + } + + if viewModel.isShowDeleteDialog { + SodaV2ActionModal( + title: I18n.Common.commentDeleteTitle, + message: I18n.Common.confirmDeleteQuestion, + button1: SodaV2ActionModalButton( + label: I18n.Common.delete, + action: viewModel.confirmDelete + ), + button2: SodaV2ActionModalButton( + label: I18n.Common.cancel, + action: dismissDeleteDialog + ), + onDimmedTap: dismissDeleteDialog + ) + } + } + .navigationBarBackButtonHidden(true) + .onAppear { + viewModel.configure(postId: postId, onCommunityRefresh: onCommunityRefresh) + } + .safeAreaInset(edge: .bottom) { + if viewModel.detail?.isCommentAvailable == true { + CreatorChannelCommunityPostDetailInputBar( + text: $viewModel.commentText, + isSecret: $viewModel.isSecret, + isShowSecret: viewModel.isShowSecret, + isSendEnabled: viewModel.isCommentSendEnabled, + isEditing: viewModel.isEditing, + onTapSend: viewModel.sendComment, + onTapCancelEditing: viewModel.cancelEditing + ) + } + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) + } + + private func dismissDeleteDialog() { + viewModel.isShowDeleteDialog = false + viewModel.deletingComment = nil + } + + private func showReplyDetail(_ comment: CreatorChannelCommunityCommentResponse) { + guard let detail = viewModel.detail else { return } + AppState.shared.setAppStep( + step: .creatorChannelReplyDetail( + context: .communityPostDetail( + creatorId: detail.creatorId, + postId: detail.postId, + parentComment: comment + ), + onCommunityRefresh: { + viewModel.fetchDetail(postId: postId) + onCommunityRefresh?() + } + ) + ) + } + + private func showPurchaseError() { + viewModel.errorMessage = I18n.Common.commonError + viewModel.isShowPopup = true + } + +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift new file mode 100644 index 00000000..93d8158e --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift @@ -0,0 +1,383 @@ +import Foundation +import Combine + +import Moya + +final class CreatorChannelCommunityPostDetailViewModel: ObservableObject { + private let repository = CreatorChannelCommunityRepository() + private let communityRepository = CreatorCommunityRepository() + private var subscription = Set() + private var postId = 0 + private var onCommunityRefresh: (() -> Void)? + private var latestRequestId = 0 + + @Published var detail: CreatorChannelCommunityPostDetailResponse? + @Published var comments = [CreatorChannelCommunityCommentResponse]() + @Published var commentText = "" + @Published var isSecret = false + @Published var isLoading = false + @Published var isLoadingNextPage = false + @Published var hasLoaded = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var selectedComment: CreatorChannelCommunityCommentResponse? + @Published var editingComment: CreatorChannelCommunityCommentResponse? + @Published var deletingComment: CreatorChannelCommunityCommentResponse? + @Published var isShowDeleteDialog = false + @Published var page = 0 + @Published var size = 20 + @Published var hasNext = false + + var isOwnPost: Bool { + detail?.creatorId == UserDefaults.int(forKey: .userId) + } + + var isShowSecret: Bool { + guard let detail else { return false } + return detail.price > 0 && detail.existOrdered && detail.creatorId != UserDefaults.int(forKey: .userId) + } + + var isCommentSendEnabled: Bool { + guard detail?.isCommentAvailable == true else { return false } + return commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && isLoading == false + } + + var isEditing: Bool { + editingComment != nil + } + + func configure(postId: Int, onCommunityRefresh: (() -> Void)?) { + self.onCommunityRefresh = onCommunityRefresh + guard self.postId != postId || hasLoaded == false else { return } + + self.postId = postId + fetchDetail(postId: postId) + } + + func fetchDetail(postId: Int) { + self.postId = postId + latestRequestId += 1 + let requestId = latestRequestId + + isLoading = true + + repository.getCommunityPostDetail(postId: postId) + .sink { [weak self] result in + guard let self else { return } + guard requestId == self.latestRequestId else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + self.isLoading = false + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self.applyFailureState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + guard requestId == self.latestRequestId else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + self.applyDetail(data) + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + print(error) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + + self.isLoading = false + self.hasLoaded = true + } + .store(in: &subscription) + } + + func fetchNextCommentsIfNeeded(currentComment: CreatorChannelCommunityCommentResponse) { + guard comments.last?.commentId == currentComment.commentId else { return } + guard detail?.isCommentAvailable == true else { return } + guard hasNext, isLoadingNextPage == false, isLoading == false else { return } + guard postId > 0 else { return } + + let requestPage = page + 1 + isLoadingNextPage = true + + repository.getCommunityPostComments(postId: postId, page: requestPage, size: size) + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + self.isLoadingNextPage = false + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self.applyFailureState() + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + self.page = data.page + self.size = data.size + self.hasNext = data.hasNext + self.comments.append(contentsOf: data.comments) + } else { + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + + self.isLoadingNextPage = false + } + .store(in: &subscription) + } + + func toggleLike() { + guard let detail, isLoading == false else { return } + + let previousDetail = detail + let nextIsLiked = !detail.isLiked + self.detail = detail.updatingLike(isLiked: nextIsLiked) + + communityRepository.communityPostLike(postId: detail.postId) + .sink { [weak self] result in + guard let self else { return } + + if case .failure(let error) = result { + ERROR_LOG(error.localizedDescription) + self.detail = previousDetail + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + if decoded.success == false { + self.detail = previousDetail + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.detail = previousDetail + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func sendComment() { + guard isCommentSendEnabled else { return } + + if let editingComment { + modify(comment: editingComment) + } else { + createComment() + } + } + + func canEdit(_ comment: CreatorChannelCommunityCommentResponse) -> Bool { + comment.writerId == UserDefaults.int(forKey: .userId) + } + + func canDelete(_ comment: CreatorChannelCommunityCommentResponse) -> Bool { + canEdit(comment) || isOwnPost + } + + func selectComment(_ comment: CreatorChannelCommunityCommentResponse) { + selectedComment = selectedComment?.commentId == comment.commentId ? nil : comment + } + + func clearSelectedComment() { + selectedComment = nil + } + + func beginEditing(_ comment: CreatorChannelCommunityCommentResponse) { + guard canEdit(comment) else { return } + selectedComment = nil + editingComment = comment + commentText = comment.content + } + + func cancelEditing() { + editingComment = nil + commentText = "" + } + + func presentDeleteDialog(_ comment: CreatorChannelCommunityCommentResponse) { + guard canDelete(comment) else { return } + selectedComment = nil + deletingComment = comment + isShowDeleteDialog = true + } + + func confirmDelete() { + guard let deletingComment, isLoading == false else { return } + isShowDeleteDialog = false + delete(comment: deletingComment) + } + + private func applyDetail(_ data: CreatorChannelCommunityPostDetailResponse) { + detail = data + page = data.comments.page + size = data.comments.size + hasNext = data.comments.hasNext + hasLoaded = true + + if data.isCommentAvailable { + comments = data.comments.comments + } else { + comments = [] + commentText = "" + isSecret = false + selectedComment = nil + editingComment = nil + deletingComment = nil + isShowDeleteDialog = false + } + } + + private func createComment() { + guard postId > 0 else { return } + + isLoading = true + communityRepository.createCommunityPostComment( + comment: commentText.trimmingCharacters(in: .whitespacesAndNewlines), + postId: postId, + parentId: nil, + isSecret: isShowSecret ? isSecret : false + ) + .sink { [weak self] result in + guard let self else { return } + + if case .failure(let error) = result { + ERROR_LOG(error.localizedDescription) + self.applyFailureState() + } + } receiveValue: { [weak self] response in + self?.handleCommentMutationResponse(response) + } + .store(in: &subscription) + } + + private func modify(comment: CreatorChannelCommunityCommentResponse) { + let content = commentText.trimmingCharacters(in: .whitespacesAndNewlines) + guard content.isEmpty == false, content != comment.content else { + errorMessage = I18n.Explorer.noChanges + isShowPopup = true + return + } + + isLoading = true + var request = ModifyCommunityPostCommentRequest(commentId: comment.commentId) + request.comment = content + communityRepository.modifyComment(request: request) + .sink { [weak self] result in + guard let self else { return } + + if case .failure(let error) = result { + ERROR_LOG(error.localizedDescription) + self.applyFailureState() + } + } receiveValue: { [weak self] response in + self?.handleCommentMutationResponse(response) + } + .store(in: &subscription) + } + + private func delete(comment: CreatorChannelCommunityCommentResponse) { + isLoading = true + var request = ModifyCommunityPostCommentRequest(commentId: comment.commentId) + request.isActive = false + communityRepository.modifyComment(request: request) + .sink { [weak self] result in + guard let self else { return } + + if case .failure(let error) = result { + ERROR_LOG(error.localizedDescription) + self.applyFailureState() + } + } receiveValue: { [weak self] response in + self?.handleCommentMutationResponse(response) + } + .store(in: &subscription) + } + + private func handleCommentMutationResponse(_ response: Moya.Response) { + isLoading = false + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + if decoded.success { + applyCommentMutationSuccessState() + onCommunityRefresh?() + fetchDetail(postId: postId) + } else { + errorMessage = decoded.message ?? I18n.Common.commonError + isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + errorMessage = I18n.Common.commonError + isShowPopup = true + } + } + + private func applyCommentMutationSuccessState() { + commentText = "" + isSecret = false + selectedComment = nil + editingComment = nil + deletingComment = nil + isShowDeleteDialog = false + } + + private func applyFailureState() { + isLoading = false + isLoadingNextPage = false + hasLoaded = true + errorMessage = I18n.Common.commonError + isShowPopup = true + } +} + +private extension CreatorChannelCommunityPostDetailResponse { + func updatingLike(isLiked: Bool) -> CreatorChannelCommunityPostDetailResponse { + let nextLikeCount = max(0, likeCount + (isLiked ? 1 : -1)) + + return CreatorChannelCommunityPostDetailResponse( + postId: postId, + creatorId: creatorId, + creatorNickname: creatorNickname, + creatorProfileUrl: creatorProfileUrl, + createdAtUtc: createdAtUtc, + content: content, + imageUrl: imageUrl, + audioUrl: audioUrl, + price: price, + isCommentAvailable: isCommentAvailable, + existOrdered: existOrdered, + likeCount: nextLikeCount, + commentCount: commentCount, + isPinned: isPinned, + isLiked: isLiked, + comments: comments + ) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift new file mode 100644 index 00000000..c316480e --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift @@ -0,0 +1,70 @@ +import Foundation + +struct CreatorChannelCommunityPostDetailResponse: Decodable, Identifiable { + let postId: Int + let creatorId: Int + let creatorNickname: String + let creatorProfileUrl: String + let createdAtUtc: String + let content: String + let imageUrl: String? + let audioUrl: String? + let price: Int + let isCommentAvailable: Bool + let existOrdered: Bool + let likeCount: Int + let commentCount: Int + let isPinned: Bool + let isLiked: Bool + let comments: CreatorChannelCommunityCommentsResponse + + var id: Int { postId } + + func relativeTimeText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now) + } +} + +struct CreatorChannelCommunityCommentsResponse: Decodable { + let commentCount: Int + let comments: [CreatorChannelCommunityCommentResponse] + let page: Int + let size: Int + let hasNext: Bool +} + +struct CreatorChannelCommunityCommentResponse: Decodable { + let commentId: Int + let writerId: Int + let writerProfileImageUrl: String + let writerNickname: String + let content: String + let createdAtUtc: String + let isSecret: Bool + let latestReply: CreatorChannelCommunityReplyResponse? + + func relativeTimeText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now) + } +} + +struct CreatorChannelCommunityReplyResponse: Decodable { + let commentId: Int + let writerId: Int + let writerProfileImageUrl: String + let writerNickname: String + let content: String + let createdAtUtc: String + + func relativeTimeText(now: Date = Date()) -> String { + DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now) + } +} + +struct CreatorChannelCommunityRepliesResponse: Decodable { + let replyCount: Int + let replies: [CreatorChannelCommunityReplyResponse] + let page: Int + let size: Int + let hasNext: Bool +} diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift index 8dd1c3f5..8330180b 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift @@ -4,6 +4,9 @@ import Moya enum CreatorChannelCommunityApi { case getCommunity(creatorId: Int, page: Int, size: Int) + case getCommunityPostDetail(postId: Int) + case getCommunityPostComments(postId: Int, page: Int, size: Int) + case getCommunityCommentReplies(commentId: Int, page: Int, size: Int) } extension CreatorChannelCommunityApi: TargetType { @@ -15,12 +18,21 @@ extension CreatorChannelCommunityApi: TargetType { switch self { case .getCommunity(let creatorId, _, _): return "/api/v2/creator-channels/\(creatorId)/community" + case .getCommunityPostDetail(let postId): + return "/api/v2/creator-channels/community-posts/\(postId)" + case .getCommunityPostComments(let postId, _, _): + return "/api/v2/creator-channels/community-posts/\(postId)/comments" + case .getCommunityCommentReplies(let commentId, _, _): + return "/api/v2/creator-channels/community-comments/\(commentId)/replies" } } var method: Moya.Method { switch self { - case .getCommunity: + case .getCommunity, + .getCommunityPostDetail, + .getCommunityPostComments, + .getCommunityCommentReplies: return .get } } @@ -32,6 +44,14 @@ extension CreatorChannelCommunityApi: TargetType { parameters: ["page": page, "size": size], encoding: URLEncoding.queryString ) + case .getCommunityPostDetail: + return .requestPlain + case .getCommunityPostComments(_, let page, let size), + .getCommunityCommentReplies(_, let page, let size): + return .requestParameters( + parameters: ["page": page, "size": size], + encoding: URLEncoding.queryString + ) } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift index 64e5e9cd..d8d3a1d3 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift @@ -10,4 +10,16 @@ final class CreatorChannelCommunityRepository { func getCommunity(creatorId: Int, page: Int, size: Int) -> AnyPublisher { return api.requestPublisher(.getCommunity(creatorId: creatorId, page: page, size: size)) } + + func getCommunityPostDetail(postId: Int) -> AnyPublisher { + return api.requestPublisher(.getCommunityPostDetail(postId: postId)) + } + + func getCommunityPostComments(postId: Int, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getCommunityPostComments(postId: postId, page: page, size: size)) + } + + func getCommunityCommentReplies(commentId: Int, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getCommunityCommentReplies(commentId: commentId, page: page, size: size)) + } } diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index 11bc1654..63f736a9 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -303,7 +303,8 @@ struct CreatorChannelView: View { CreatorChannelCommunityTabView( creatorId: creatorId, isOwnCreatorChannel: isOwnCreatorChannel, - viewModel: communityViewModel + viewModel: communityViewModel, + onTapDetail: showCommunityPostDetail ) } else if viewModel.selectedTab != .home { CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) @@ -457,6 +458,17 @@ struct CreatorChannelView: View { ) } + private func showCommunityPostDetail(_ post: CreatorChannelCommunityPostItem) { + AppState.shared.setAppStep( + step: .creatorChannelCommunityPostDetail( + postId: post.postId, + onCommunityRefresh: { + communityViewModel.fetchFirstPage(creatorId: creatorId) + } + ) + ) + } + private func showAudioContentUpload() { isCreatorActionMenuPresented = false AppState.shared.setAppStep( diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift index 4594a376..29f72d2e 100644 --- a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift @@ -3,6 +3,7 @@ import SwiftUI struct CreatorChannelReplyDetailView: View { let context: CreatorChannelReplyDetailContext let onFanTalkRefresh: (() -> Void)? + let onCommunityRefresh: (() -> Void)? @StateObject private var viewModel = CreatorChannelReplyDetailViewModel() @@ -62,7 +63,7 @@ struct CreatorChannelReplyDetailView: View { } .navigationBarBackButtonHidden(true) .onAppear { - viewModel.configure(context: context, onFanTalkRefresh: onFanTalkRefresh) + viewModel.configure(context: context, onFanTalkRefresh: onFanTalkRefresh, onCommunityRefresh: onCommunityRefresh) } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) } diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift index 7280b396..88800768 100644 --- a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift @@ -4,12 +4,14 @@ import Moya final class CreatorChannelReplyDetailViewModel: ObservableObject { private let communityRepository = CreatorCommunityRepository() + private let v2CommunityRepository = CreatorChannelCommunityRepository() private let explorerRepository = ExplorerRepository() private let fanTalkRepository = CreatorChannelFanTalkRepository() private var subscription = Set() private var context: CreatorChannelReplyDetailContext? private var onFanTalkRefresh: (() -> Void)? - private var communityReplyPage = 1 + private var onCommunityRefresh: (() -> Void)? + private var communityReplyPage = 0 private let communityReplyPageSize = 20 private var communityReplyTotalCount = 0 private var isCommunityReplyLast = false @@ -35,16 +37,20 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { editingItem != nil } - func configure(context: CreatorChannelReplyDetailContext, onFanTalkRefresh: (() -> Void)?) { + func configure(context: CreatorChannelReplyDetailContext, onFanTalkRefresh: (() -> Void)?, onCommunityRefresh: (() -> Void)? = nil) { guard self.context == nil else { return } self.context = context self.onFanTalkRefresh = onFanTalkRefresh + self.onCommunityRefresh = onCommunityRefresh switch context { case .community(_, _, let parentComment): parentItem = CreatorChannelReplyDetailDisplayItem(communityComment: parentComment) fetchCommunityReplies(reset: true) + case .communityPostDetail(let creatorId, _, let parentComment): + parentItem = CreatorChannelReplyDetailDisplayItem(communityPostDetail: parentComment, creatorId: creatorId) + fetchCommunityReplies(reset: true) case .fanTalk(let creatorId, let parentFanTalk): parentItem = CreatorChannelReplyDetailDisplayItem(fanTalk: parentFanTalk) replies = parentFanTalk.creatorReplies.map { @@ -70,6 +76,9 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { if case .community(let creatorId, _, _) = context { return creatorId == UserDefaults.int(forKey: .userId) } + if case .communityPostDetail(let creatorId, _, _) = context { + return creatorId == UserDefaults.int(forKey: .userId) + } return false case .fanTalk: guard item.id > 0 else { return false } @@ -127,11 +136,11 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { } func fetchCommunityReplies(reset: Bool = false) { - guard case .community(_, _, let parentComment) = context else { return } + guard isCommunityContext else { return } guard isLoading == false, isLoadingNextPage == false else { return } if reset { - communityReplyPage = 1 + communityReplyPage = isCommunityPostDetailContext ? 0 : 1 communityReplyTotalCount = 0 isCommunityReplyLast = false } else if isCommunityReplyLast { @@ -145,7 +154,8 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { } let requestPage = communityReplyPage - communityRepository.getCommentReplyList(commentId: parentComment.id, page: requestPage, size: communityReplyPageSize) + let publisher = communityRepliesPublisher(page: requestPage) + publisher .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { @@ -157,27 +167,7 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { self.isLoading = false self.isLoadingNextPage = false - do { - let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) - if let data = decoded.data, decoded.success { - let newReplies = data.items.map { CreatorChannelReplyDetailDisplayItem(communityComment: $0) } - if reset || requestPage == 1 { - self.replies = newReplies - } else { - self.replies.append(contentsOf: newReplies) - } - self.communityReplyTotalCount = data.totalCount - self.communityReplyPage = requestPage + 1 - self.isCommunityReplyLast = self.replies.count >= data.totalCount || newReplies.isEmpty - } else { - self.errorMessage = decoded.message ?? I18n.Common.commonError - self.isShowPopup = true - } - } catch { - ERROR_LOG(error.localizedDescription) - self.errorMessage = I18n.Common.commonError - self.isShowPopup = true - } + self.handleCommunityRepliesResponse(response, requestPage: requestPage, reset: reset) } .store(in: &subscription) } @@ -199,6 +189,24 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { parentId: parentComment.id, isSecret: parentComment.isSecret ) + .sink { [weak self] result in + guard let self else { return } + if case .failure(let error) = result { + ERROR_LOG(error.localizedDescription) + self.applyFailureState() + } + } receiveValue: { [weak self] response in + self?.handleCommunityMutationResponse(response) + } + .store(in: &subscription) + case .communityPostDetail(_, let postId, let parentComment): + isLoading = true + communityRepository.createCommunityPostComment( + comment: content, + postId: postId, + parentId: parentComment.commentId, + isSecret: false + ) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { @@ -350,6 +358,7 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) if decoded.success { applyLocalSuccessState() + onCommunityRefresh?() fetchCommunityReplies(reset: true) } else { errorMessage = decoded.message ?? I18n.Common.commonError @@ -410,7 +419,93 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject { private func applyFailureState() { isLoading = false + isLoadingNextPage = false errorMessage = I18n.Common.commonError isShowPopup = true } + + private var isCommunityContext: Bool { + if case .community = context { return true } + if case .communityPostDetail = context { return true } + return false + } + + private var isCommunityPostDetailContext: Bool { + if case .communityPostDetail = context { return true } + return false + } + + private func communityRepliesPublisher(page: Int) -> AnyPublisher { + switch context { + case .community(_, _, let parentComment): + return communityRepository.getCommentReplyList(commentId: parentComment.id, page: page, size: communityReplyPageSize) + case .communityPostDetail(_, _, let parentComment): + return v2CommunityRepository.getCommunityCommentReplies(commentId: parentComment.commentId, page: page, size: communityReplyPageSize) + default: + return Fail(error: MoyaError.underlying(NSError(domain: "CreatorChannelReplyDetail", code: -1), nil)).eraseToAnyPublisher() + } + } + + private func handleCommunityRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) { + if isCommunityPostDetailContext { + handleCommunityPostDetailRepliesResponse(response, requestPage: requestPage, reset: reset) + } else { + handleLegacyCommunityRepliesResponse(response, requestPage: requestPage, reset: reset) + } + } + + private func handleLegacyCommunityRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) { + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + let newReplies = data.items.map { CreatorChannelReplyDetailDisplayItem(communityComment: $0) } + applyCommunityReplies(newReplies, requestPage: requestPage, reset: reset, totalCount: data.totalCount, hasNext: nil) + } else { + errorMessage = decoded.message ?? I18n.Common.commonError + isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + errorMessage = I18n.Common.commonError + isShowPopup = true + } + } + + private func handleCommunityPostDetailRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) { + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + let creatorId = contextCreatorId + let newReplies = data.replies.map { CreatorChannelReplyDetailDisplayItem(communityPostDetailReply: $0, creatorId: creatorId) } + applyCommunityReplies(newReplies, requestPage: requestPage, reset: reset, totalCount: data.replyCount, hasNext: data.hasNext) + } else { + errorMessage = decoded.message ?? I18n.Common.commonError + isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + errorMessage = I18n.Common.commonError + isShowPopup = true + } + } + + private var contextCreatorId: Int { + switch context { + case .community(let creatorId, _, _), .communityPostDetail(let creatorId, _, _), .fanTalk(let creatorId, _): + return creatorId + case .none: + return 0 + } + } + + private func applyCommunityReplies(_ newReplies: [CreatorChannelReplyDetailDisplayItem], requestPage: Int, reset: Bool, totalCount: Int, hasNext: Bool?) { + if reset || requestPage == (isCommunityPostDetailContext ? 0 : 1) { + replies = newReplies + } else { + replies.append(contentsOf: newReplies) + } + communityReplyTotalCount = totalCount + communityReplyPage = requestPage + 1 + isCommunityReplyLast = hasNext.map { !$0 } ?? (replies.count >= totalCount || newReplies.isEmpty) + } } diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift index a5f6899d..f3997cf1 100644 --- a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift @@ -6,6 +6,11 @@ enum CreatorChannelReplyDetailContext { postId: Int, parentComment: GetCommunityPostCommentListItem ) + case communityPostDetail( + creatorId: Int, + postId: Int, + parentComment: CreatorChannelCommunityCommentResponse + ) case fanTalk( creatorId: Int, parentFanTalk: CreatorChannelFanTalkItemResponse diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift index 6db52bc5..7d792c78 100644 --- a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift @@ -74,6 +74,38 @@ extension CreatorChannelReplyDetailDisplayItem { ) } + init(communityPostDetail comment: CreatorChannelCommunityCommentResponse, creatorId: Int) { + let currentUserId = UserDefaults.int(forKey: .userId) + self.init( + id: comment.commentId, + source: .community, + writerId: comment.writerId, + nickname: comment.writerNickname, + profileImageUrl: comment.writerProfileImageUrl, + content: comment.content, + relativeTimeText: DateParser.relativeTimeText(fromUTC: comment.createdAtUtc, fallback: comment.createdAtUtc), + isSecret: comment.isSecret, + isMine: comment.writerId == currentUserId, + isCreatorReply: comment.writerId == creatorId + ) + } + + init(communityPostDetailReply reply: CreatorChannelCommunityReplyResponse, creatorId: Int) { + let currentUserId = UserDefaults.int(forKey: .userId) + self.init( + id: reply.commentId, + source: .community, + writerId: reply.writerId, + nickname: reply.writerNickname, + profileImageUrl: reply.writerProfileImageUrl, + content: reply.content, + relativeTimeText: reply.relativeTimeText(), + isSecret: false, + isMine: reply.writerId == currentUserId, + isCreatorReply: reply.writerId == creatorId + ) + } + init(fanTalkReply: CreatorChannelFanTalkReplyResponse, creatorId: Int) { let currentUserId = UserDefaults.int(forKey: .userId) self.init( diff --git a/docs/20260707_커뮤니티_게시글_상세/plan-task.md b/docs/20260707_커뮤니티_게시글_상세/plan-task.md new file mode 100644 index 00000000..439618b3 --- /dev/null +++ b/docs/20260707_커뮤니티_게시글_상세/plan-task.md @@ -0,0 +1,379 @@ +# 커뮤니티 게시글 상세 구현 계획 + +> **For agentic workers:** PRD를 기준으로 task 단위로 진행한다. 구현 중 요구사항 변경이 생기면 먼저 `docs/20260707_커뮤니티_게시글_상세/prd.md`를 보강하고 이 문서의 체크리스트를 갱신한다. + +**Goal:** V2 커뮤니티 게시글 상세 화면을 추가하고, 상세/댓글/답글 조회 API와 기존 커뮤니티 mutation API를 연결한다. + +**Architecture:** 상세/댓글/답글 조회는 `SodaLive/Sources/V2/CreatorChannel/Community/**`의 V2 API/Repository에 추가한다. 상세 화면 상태는 신규 `Community/Detail` ViewModel이 관리하고, 댓글 작성/수정/삭제/좋아요 mutation은 기존 `CreatorCommunityRepository`를 재사용한다. 댓글의 답글 상세 화면은 기존 `CreatorChannelReplyDetailView` 계열을 복사하지 않고 context/display adapter를 확장해 재사용한다. + +**Tech Stack:** SwiftUI, Combine, Moya, Kingfisher/SDWebImageSwiftUI, 기존 `AppStep`/`AppState`, 기존 V2 design token(`Color`, `SodaSpacing`, `appFont`) + +--- + +## 기준 문서 + +- PRD: `docs/20260707_커뮤니티_게시글_상세/prd.md` +- 문서 정책: `docs/agent-guides/documentation-policy.md` +- 빌드/검증: `docs/agent-guides/build-test-verification.md` +- 커뮤니티 탭 PRD: `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md` +- 글 상세 - 답글 쓰기 PRD: `docs/20260707_글_상세_답글_쓰기/prd.md` +- Figma 댓글 가능 상세: `290:9243`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9243&m=dev` +- Figma 댓글 불가능 상세: `290:9266`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9266&m=dev` + +## 구현 원칙 + +- 신규 상세 화면 파일은 `SodaLive/Sources/V2/CreatorChannel/Community/Detail/**` 아래에 둔다. +- 신규 조회 모델은 `SodaLive/Sources/V2/CreatorChannel/Community/Models/**` 아래에 둔다. +- 기존 `CreatorChannelCommunityApi.getCommunity`의 `page=0` 정책을 유지하고, 신규 V2 조회 API에도 `page - 1` 보정을 넣지 않는다. +- 댓글 작성/답글 작성/댓글 수정/댓글 삭제/좋아요 API는 기존 `CreatorCommunityRepository`를 사용한다. +- 댓글 불가능 게시글은 좋아요 UI는 유지하고, 댓글 icon/count, 댓글 목록, 하단 댓글 입력 바를 숨긴다. +- 댓글 입력창은 3줄까지 자동 확장하고 4줄 이상은 내부 스크롤로 처리한다. +- 전송 버튼 icon은 `ic_arrow_up_gray`, `ic_arrow_up_white`를 사용한다. +- 댓글 쓰기 UI에는 기존 비밀댓글 toggle을 추가하고, 기존 `isShowSecret` 노출 정책을 따른다. +- 답글 상세 UI는 기존 `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/**` 구현을 재사용한다. + +## 주요 대상 파일 + +### 생성 +- `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift` + +### 수정 +- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift` +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift` +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift` +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift` +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift` +- `SodaLive/Sources/App/AppStep.swift` +- `SodaLive/Sources/ContentView.swift` +- `SodaLive/Sources/I18n/I18n.swift` +- `SodaLive.xcodeproj/project.pbxproj` + +### 확인/재사용 +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift` +- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift` +- `SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkListItem.swift` +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift` +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Comment/ModifyCommunityPostCommentRequest.swift` + +## TASK 체크리스트 + +### Phase 1: 조회 모델과 API + +- [x] **Task 1.1: 커뮤니티 게시글 상세 응답 모델 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift` + - 작업 내용: + - `CreatorChannelCommunityPostDetailResponse`를 `Decodable`, `Identifiable`로 정의한다. + - `CreatorChannelCommunityCommentsResponse`, `CreatorChannelCommunityCommentResponse`, `CreatorChannelCommunityReplyResponse`, `CreatorChannelCommunityRepliesResponse`를 정의한다. + - Kotlin `Long` 필드는 Swift `Int`로 선언한다. + - `CreatorChannelCommunityCommentResponse`와 `CreatorChannelCommunityReplyResponse`에는 `writerId: Int`를 포함한다. + - 각 시간 필드는 `DateParser.relativeTimeText(fromUTC:fallback:now:)`를 사용하는 helper를 제공한다. + - 검증 기준: + - 실행 명령: `rg "struct CreatorChannelCommunityPostDetailResponse|struct CreatorChannelCommunityCommentsResponse|struct CreatorChannelCommunityCommentResponse|writerId|struct CreatorChannelCommunityRepliesResponse|relativeTimeText" SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift` + - 기대 결과: 상세/댓글/답글 모델과 `writerId`, 상대 시간 helper가 확인된다. + +- [x] **Task 1.2: 커뮤니티 게시글 상세/댓글/답글 조회 API 추가** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift` + - 작업 내용: + - `CreatorChannelCommunityApi`에 `getCommunityPostDetail(postId:)`, `getCommunityPostComments(postId:page:size:)`, `getCommunityCommentReplies(commentId:page:size:)` case를 추가한다. + - path는 각각 `/api/v2/creator-channels/community-posts/{postId}`, `/api/v2/creator-channels/community-posts/{postId}/comments`, `/api/v2/creator-channels/community-comments/{commentId}/replies`로 구성한다. + - 댓글/답글 query는 `page`, `size`를 그대로 전달하고 `page - 1` 보정을 하지 않는다. + - Repository에 동일 이름의 publisher 함수를 추가한다. + - 검증 기준: + - 실행 명령: `rg "getCommunityPostDetail|getCommunityPostComments|getCommunityCommentReplies|community-posts|community-comments|URLEncoding.queryString" SodaLive/Sources/V2/CreatorChannel/Community/Repository` + - 기대 결과: 신규 endpoint 3개와 query 전달이 확인된다. + +### Phase 2: 라우팅과 목록 진입 + +- [x] **Task 2.1: 게시글 상세 route 추가** + - 대상 파일: + - 수정: `SodaLive/Sources/App/AppStep.swift` + - 수정: `SodaLive/Sources/ContentView.swift` + - 작업 내용: + - `AppStep`에 `creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)` case를 추가한다. + - `ContentView` switch에서 `CreatorChannelCommunityPostDetailView(postId:onCommunityRefresh:)`를 반환한다. + - `onCommunityRefresh`는 상세 mutation 성공 후 커뮤니티 탭 첫 페이지를 갱신하기 위한 optional callback으로 둔다. + - 검증 기준: + - 실행 명령: `rg "creatorChannelCommunityPostDetail|CreatorChannelCommunityPostDetailView|onCommunityRefresh" SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift` + - 기대 결과: 새 route와 화면 연결이 확인된다. + +- [x] **Task 2.2: 커뮤니티 리스트/그리드 item tap에서 상세 진입 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift` + - 작업 내용: + - 리스트 item과 그리드 item에 `onTapDetail` closure를 추가한다. + - item 전체 tap은 상세 route로 연결한다. + - 좋아요, 댓글, 더보기, 구매, 오디오 재생 버튼 tap은 상세 진입과 충돌하지 않게 button action으로 유지한다. + - 상세 route에는 `post.postId`와 `viewModel.fetchFirstPage(creatorId:)` callback을 전달한다. + - 검증 기준: + - 실행 명령: `rg "onTapDetail|creatorChannelCommunityPostDetail|fetchFirstPage\\(creatorId: creatorId\\)|contentShape|Button" SodaLive/Sources/V2/CreatorChannel/Community` + - 기대 결과: 리스트/그리드 item tap 상세 진입과 내부 버튼 action 분리가 확인된다. + +### Phase 3: 상세 ViewModel과 mutation 재사용 + +- [x] **Task 3.1: 상세 ViewModel 기본 상태와 상세 조회 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 작업 내용: + - 상태로 `detail`, `comments`, `commentText`, `isSecret`, `isLoading`, `isLoadingNextPage`, `hasLoaded`, `errorMessage`, `isShowPopup`, `selectedComment`, `editingComment`, `deletingComment`를 둔다. + - `fetchDetail(postId:)`에서 상세 API를 호출하고 게시글과 초기 댓글 목록을 구성한다. + - `isCommentAvailable == false`이면 댓글 입력/목록 상태를 비활성 상태로 유지한다. + - `isShowSecret` computed property는 기존 정책인 `price > 0 && existOrdered && creatorId != UserDefaults.int(forKey: .userId)`를 따른다. + - `isCommentSendEnabled`는 trim된 `commentText`가 비어 있지 않고 로딩 중이 아닐 때 true로 계산한다. + - 검증 기준: + - 실행 명령: `rg "final class CreatorChannelCommunityPostDetailViewModel|fetchDetail|detail|comments|commentText|isSecret|isShowSecret|isCommentSendEnabled|getCommunityPostDetail" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 기대 결과: 상세 조회와 댓글 입력 상태가 확인된다. + +- [x] **Task 3.2: 댓글 페이지네이션 구현** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 작업 내용: + - `fetchNextCommentsIfNeeded(currentComment:)`를 추가한다. + - 마지막 댓글 노출 시 `hasNext == true`이면 `page + 1`로 댓글 API를 호출한다. + - 중복 호출을 막기 위해 `isLoadingNextPage`와 `isLoading` guard를 둔다. + - 첫 페이지 재조회 시 기존 댓글 목록을 교체하고, 다음 페이지 성공 시 append한다. + - 검증 기준: + - 실행 명령: `rg "fetchNextCommentsIfNeeded|getCommunityPostComments|isLoadingNextPage|hasNext|page \\+ 1|append\\(contentsOf:" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 기대 결과: 댓글 추가 조회와 append 로직이 확인된다. + +- [x] **Task 3.3: 좋아요 mutation과 optimistic state 구현** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 작업 내용: + - `toggleLike()`에서 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 호출한다. + - tap 직후 `isLiked`와 `likeCount`를 local state에 반영한다. + - 실패 시 toast를 표시하고 tap 직전의 `isLiked`, `likeCount` 값으로 local state를 복구한다. + - 검증 기준: + - 실행 명령: `rg "toggleLike|communityPostLike|isLiked|likeCount|errorMessage|isShowPopup" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 기대 결과: 기존 좋아요 API와 local state 반영이 확인된다. + +- [x] **Task 3.4: 댓글 작성/수정/삭제 mutation 구현** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 작업 내용: + - 신규 댓글 작성은 `CreatorCommunityRepository.createCommunityPostComment(comment:postId:parentId:isSecret:)`를 사용하고 `parentId == nil`, `isSecret == 비밀댓글 toggle 값`으로 호출한다. + - 댓글 수정은 `ModifyCommunityPostCommentRequest(commentId:)`에 수정 content를 담아 `modifyComment(request:)`를 호출한다. + - 댓글 삭제는 `ModifyCommunityPostCommentRequest(commentId:)`에 `isActive = false`를 담아 `modifyComment(request:)`를 호출한다. + - 성공 후 입력값과 수정/삭제 선택 상태를 초기화하고 상세 또는 댓글 첫 페이지를 재조회한다. + - mutation 성공 시 `onCommunityRefresh` callback을 호출해 목록 갱신을 예약한다. + - 검증 기준: + - 실행 명령: `rg "createCommunityPostComment|parentId: nil|isSecret|ModifyCommunityPostCommentRequest|modifyComment|isActive = false|onCommunityRefresh|commentText = \"\"" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 기대 결과: 기존 커뮤니티 댓글 mutation API 재사용이 확인된다. + +### Phase 4: 상세 UI 컴포넌트 + +- [x] **Task 4.1: 상세 게시글 콘텐츠 컴포넌트 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift` + - 작업 내용: + - Figma 기준으로 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/잠금/오디오 영역, 좋아요/댓글 reaction을 표시한다. + - 유료 미구매 상태는 `price > 0 && existOrdered == false && isOwnPost == false`로 판정한다. + - 유료 미구매 상태에서는 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다. + - 구매 완료 상태는 `구매완료` capsule tag를 표시한다. + - 접근 가능한 이미지/GIF는 기존 `WebImage` 또는 `DownsampledKFImage` 패턴을 따른다. + - `audioUrl`이 있으면 기존 `CreatorCommunityMediaPlayerManager`와 `ContentPlayManager` 흐름을 재사용한다. + - `isCommentAvailable == false`이면 댓글 icon/count만 숨기고 좋아요는 유지한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityPostDetailContentView|ic_feed_community_heart_fill|ic_feed_community_heart|ic_feed_community_reply|ic_new_community_lock|ic_bar_cash|구매완료|CreatorCommunityMediaPlayerManager|isCommentAvailable" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift` + - 기대 결과: 게시글 상세 표시, 좋아요, 댓글 숨김, 잠금/구매완료 UI가 확인된다. + +- [x] **Task 4.2: 댓글 row와 최신 답글 카드 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift` + - 작업 내용: + - 댓글 row는 42pt 프로필, 닉네임, 상대 시간, 본문, 우측 더보기 버튼을 표시한다. + - `writerId == UserDefaults.int(forKey: .userId)`로 내 댓글 여부를 판정한다. + - `post.creatorId == UserDefaults.int(forKey: .userId)`로 현재 사용자가 해당 게시글을 쓴 크리에이터인지 판정한다. + - `latestReply != nil`이면 20pt 프로필, 닉네임, 상대 시간, 본문을 가진 `Color.gray900`, radius 14pt 답글 카드를 표시한다. + - 최신 답글이 있으면 기존 팬Talk/ReplyDetail connector와 같은 좌측 연결선을 표시한다. + - row tap은 답글 상세 진입 action을 호출한다. + - 더보기 버튼 tap은 popup/action 선택으로 연결하고 row tap과 충돌하지 않게 한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityPostDetailCommentRow|latestReply|writerId|creatorId|UserDefaults.int|Color\\.gray900|RoundedRectangle|Shape|Path|onTapReplyDetail|onTapMore" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift` + - 기대 결과: 댓글 row, 최신 답글 카드, 연결선, action 분리가 확인된다. + +- [x] **Task 4.3: 댓글 목록 컴포넌트 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift` + - 작업 내용: + - section header는 `댓글 {commentCount}`를 표시한다. + - 댓글 목록은 `LazyVStack`으로 표시하고 마지막 댓글 `onAppear`에서 다음 페이지 조회를 요청한다. + - 댓글이 비어 있으면 header만 유지하거나 Figma 기준 빈 목록 영역을 유지한다. + - 선택된 댓글의 popup은 기존 `CreatorChannelActionPopup` 또는 `CreatorChannelReplyDetailActionPopup` 기반으로 수정/삭제 action을 표시한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityPostDetailCommentListView|댓글|commentCount|LazyVStack|onAppear|fetchNext|CreatorChannelActionPopup|onTapEdit|onTapDelete" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift` + - 기대 결과: 댓글 목록, 페이지네이션 trigger, popup action 연결이 확인된다. + +- [x] **Task 4.4: 하단 댓글 입력 바 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - placeholder는 `댓글을 입력하세요`를 사용한다. + - 기존 비밀댓글 toggle UI를 추가하고 `isShowSecret`일 때만 표시한다. + - 입력창은 1줄에서 3줄까지 자동 확장한다. + - 4줄 이상은 3줄 높이로 유지하고 내부 vertical scroll을 허용한다. + - 전송 버튼은 비활성 상태에서 `ic_arrow_up_gray`/`gray900`, 활성 상태에서 `ic_arrow_up_white`/`soda400`를 사용한다. + - 수정 모드에서는 취소 버튼 또는 기존 입력 복귀 UX를 최소 범위로 제공한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityPostDetailInputBar|댓글을 입력하세요|isShowSecret|isSecret|ic_arrow_up_gray|ic_arrow_up_white|Color\\.gray900|Color\\.soda400|axis: \\.vertical|lineLimit" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: placeholder, 비밀댓글 toggle, 3줄 확장, 전송 버튼 상태가 확인된다. + +- [x] **Task 4.5: 상세 화면 조립** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift` + - 작업 내용: + - `CreatorChannelReplyDetailTitleBar`를 재사용해 back-only title bar를 표시한다. + - `ScrollView` 안에 게시글 콘텐츠와 댓글 목록을 배치한다. + - `isCommentAvailable == true`일 때만 하단 입력 바를 `safeAreaInset(edge: .bottom)`으로 고정한다. + - loading, toast, 삭제 confirm modal을 연결한다. + - `onAppear`에서 `viewModel.fetchDetail(postId:)`를 호출한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityPostDetailView|CreatorChannelReplyDetailTitleBar|CreatorChannelCommunityPostDetailContentView|CreatorChannelCommunityPostDetailCommentListView|CreatorChannelCommunityPostDetailInputBar|safeAreaInset|fetchDetail|SodaV2ActionModal|sodaToast" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift` + - 기대 결과: 상세 화면 조립과 댓글 가능 여부별 하단 입력 바 조건이 확인된다. + +### Phase 5: 답글 상세 재사용 확장 + +- [x] **Task 5.1: 신규 댓글 응답을 답글 상세 context로 전달할 수 있게 확장** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift` + - 작업 내용: + - 기존 `.community(creatorId:postId:parentComment: GetCommunityPostCommentListItem)` 경로는 유지한다. + - 신규 상세 댓글 응답을 전달할 수 있는 context case 또는 adapter를 추가한다. + - `CreatorChannelCommunityCommentResponse`는 `commentId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`로 display item을 만든다. + - 신규 응답에는 `isSecret`이 없으므로 원댓글 표시용 `isSecret`은 false로 처리하고, 신규 댓글 입력의 비밀 여부는 상세 댓글 작성 UI에서만 다룬다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityCommentResponse|communityPostDetail|commentId|writerId|writerProfileImageUrl|DateParser.relativeTimeText|isSecret: false" SodaLive/Sources/V2/CreatorChannel/ReplyDetail` + - 기대 결과: 신규 댓글 응답에서 답글 상세 display item을 만들 수 있다. + +- [x] **Task 5.2: 답글 상세에서 신규 답글 조회 API 사용** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift` + - 작업 내용: + - 신규 상세 댓글 context에서는 `CreatorChannelCommunityRepository.getCommunityCommentReplies(commentId:page:size:)`를 사용한다. + - 기존 V1 댓글 context는 기존 `CreatorCommunityRepository.getCommentReplyList(commentId:page:size:)` 경로를 유지한다. + - 신규 `CreatorChannelCommunityReplyResponse`를 `CreatorChannelReplyDetailDisplayItem`으로 변환한다. + - `hasNext`, `page`, `size`를 사용해 답글 페이지네이션을 처리한다. + - 검증 기준: + - 실행 명령: `rg "getCommunityCommentReplies|CreatorChannelCommunityRepliesResponse|CreatorChannelCommunityReplyResponse|hasNext|communityReplyPage|fetchNextCommunityRepliesIfNeeded" SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift` + - 기대 결과: 신규 답글 조회 API와 기존 V1 답글 조회 경로가 분리되어 확인된다. + +- [x] **Task 5.3: 댓글 row tap에서 답글 상세 route 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift` + - 수정: `SodaLive/Sources/App/AppStep.swift` + - 수정: `SodaLive/Sources/ContentView.swift` + - 작업 내용: + - 댓글 row tap 시 `AppState.shared.setAppStep(step: .creatorChannelReplyDetail(...))`을 호출한다. + - context에는 `creatorId`, `postId`, 선택 댓글 응답을 전달한다. + - `CreatorChannelReplyDetailView`에서 커뮤니티 답글 mutation 성공 시 호출할 `onCommunityRefresh` callback을 추가하고, 게시글 상세 진입 시 `viewModel.fetchDetail(postId:)`를 전달한다. + - 검증 기준: + - 실행 명령: `rg "creatorChannelReplyDetail|CreatorChannelReplyDetailContext|CreatorChannelCommunityCommentResponse|onTapReplyDetail|onCommunityRefresh|postId|creatorId" SodaLive/Sources/V2/CreatorChannel/Community/Detail SodaLive/Sources/V2/CreatorChannel/ReplyDetail SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift` + - 기대 결과: 상세 댓글에서 기존 답글 상세 화면으로 진입하는 route가 확인된다. + +### Phase 6: 권한, 문구, 프로젝트 등록 + +- [x] **Task 6.1: 댓글 popup 권한과 수정/삭제 모드 연결, 신고 차단 기록** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift` + - 작업 내용: + - 내 댓글/답글이면 `수정하기`, `삭제하기`를 표시한다. + - 내가 해당 커뮤니티 게시글을 쓴 크리에이터이면 내 댓글/답글이 아니어도 `삭제하기`를 표시한다. + - `수정하기` tap 시 하단 입력창에 기존 댓글 내용을 채우고 send 시 수정 API를 호출한다. + - `삭제하기` tap 시 `SodaV2ActionModal`로 삭제 확인을 표시한다. + - [blocked] 신고는 기존 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 없고, 기존 `CreatorCommunityCommentItemView`도 신고 action을 노출하지 않아 연결하지 않는다. 백엔드/API 계약에 community comment report identifier가 추가된 뒤 별도 구현한다. + - 검증 기준: + - 실행 명령: `rg "writerId ==|creatorId ==|currentUserId|canEdit|canDelete|beginEditing|editingComment|deletingComment|SodaV2ActionModal|confirmDelete|CreatorChannelActionPopup" SodaLive/Sources/V2/CreatorChannel/Community/Detail` + - 기대 결과: 작성자 수정/삭제 권한, 게시글 크리에이터 삭제 권한, 수정 모드가 확인된다. 신고 action은 community comment report identifier API 계약 부재로 노출하지 않는다. + +- [x] **Task 6.2: I18n 문구와 asset 참조 확인** + - 대상 파일: + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/Contents.json` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/Contents.json` + - 작업 내용: + - `I18n.CreatorChannelCommunity.commentPlaceholder` 또는 같은 범위의 문구로 `댓글을 입력하세요` ko/en/ja를 추가한다. + - 비밀댓글 toggle 문구는 기존 `I18n.Explorer.secretComment`를 재사용한다. + - `ic_arrow_up_gray`, `ic_arrow_up_white` asset이 존재하는지 확인하고 다른 arrow asset 이름을 참조하지 않는다. + - 검증 기준: + - 실행 명령: `rg "commentPlaceholder|댓글을 입력하세요|secretComment|ic_arrow_up_gray|ic_arrow_up_white" SodaLive/Sources/I18n/I18n.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail SodaLive/Resources/Assets.xcassets/v2` + - 기대 결과: 문구와 icon 참조가 확정된 asset 이름과 일치한다. + +- [x] **Task 6.3: 프로젝트 파일 등록 확인** + - 대상 파일: + - 확인/수정: `SodaLive.xcodeproj/project.pbxproj` + - 작업 내용: + - 신규 Swift 파일이 Xcode target에 포함되는지 확인한다. + - 수동 등록이 필요한 경우 기존 V2 파일 등록 패턴을 따라 최소 변경으로 추가한다. + - 검증 기준: + - 실행 명령: `rg "CreatorChannelCommunityPostDetailView|CreatorChannelCommunityPostDetailViewModel|CreatorChannelCommunityPostDetailResponse|CreatorChannelCommunityPostDetailInputBar" SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: 신규 Swift 파일의 project 등록이 확인된다. + +### Phase 7: 최종 검증 + +- [x] **Task 7.1: 정적 문서/파일 검증** + - 대상 파일: + - 확인: `docs/20260707_커뮤니티_게시글_상세/prd.md` + - 확인: `docs/20260707_커뮤니티_게시글_상세/plan-task.md` + - 확인: 구현 변경 파일 전체 + - 검증 기준: + - 실행 명령: `git diff --check -- docs/20260707_커뮤니티_게시글_상세 SodaLive/Sources/V2/CreatorChannel/Community SodaLive/Sources/V2/CreatorChannel/ReplyDetail SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: 공백 오류 없이 성공한다. + - 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: `OK`가 출력된다. + +- [x] **Task 7.2: 빌드 검증** + - 대상 파일: + - 확인: 전체 앱 target + - 검증 기준: + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: Debug 빌드 성공. + - 참고: 테스트 번들 타깃이 확인되지 않는 저장소 상태이므로, 단위 테스트가 추가되지 않은 경우 빌드 검증을 우선 성공 기준으로 둔다. + +- [ ] **Task 7.3: 수동 QA 시나리오 기록** + - 대상 파일: + - 수정: `docs/20260707_커뮤니티_게시글_상세/plan-task.md` + - 검증 기준: + - 댓글 가능 게시글 상세 진입 시 상세 API가 호출되고 게시글/댓글/입력 바가 표시된다. + - 댓글 불가능 게시글 상세 진입 시 좋아요는 표시되고 댓글 icon/count, 댓글 목록, 입력 바는 숨겨진다. + - 댓글 입력값이 없으면 `ic_arrow_up_gray`/`gray900`, 입력값이 있으면 `ic_arrow_up_white`/`soda400`가 표시된다. + - 4줄 이상 입력 시 입력창 높이가 3줄로 유지되고 내부 스크롤된다. + - 비밀댓글 toggle 노출 조건에서 toggle 값이 댓글 작성 API의 `isSecret`으로 전달된다. + - 댓글 row tap 시 기존 답글 상세 화면으로 진입하고 신규 답글 조회 API가 호출된다. + - 게시글을 쓴 크리에이터 계정에서는 내가 작성하지 않은 댓글/답글에도 `삭제하기`가 표시된다. + - 작성자도 게시글 크리에이터도 아닌 계정에서는 신고 action이 표시되지 않는다. [blocked] 기존 `ReportRequest`/`ReportType`에 community comment report identifier가 없어 백엔드/API 계약 확정 전까지 수동 QA 대상에서 제외한다. + - 하트 tap 시 기존 좋아요 API가 호출되고 icon/count가 갱신된다. + - 실행 후 이 문서의 `검증 기록` 섹션에 실행 일자, 명령, 결과를 누적한다. + +## 검증 기록 + +- 2026-07-07: `docs/20260707_커뮤니티_게시글_상세/prd.md`, `docs/agent-guides/build-test-verification.md`, 기존 `docs/20260707_글_상세_답글_쓰기/plan-task.md`, `docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md`를 확인해 구현 범위와 문서 형식을 정했다. +- 2026-07-07: 현재 코드 기준으로 `AppStep.creatorChannelReplyDetail`, `CreatorChannelReplyDetailView`, V2 커뮤니티 탭 파일, V2 커뮤니티 API/Repository, 답글 상세 display 모델이 이미 존재함을 확인하고, 게시글 상세는 신규 `Community/Detail` 하위 구현으로 계획했다. +- 2026-07-07: 기존 `CreatorCommunityCommentItemView`에서 게시글 크리에이터 또는 댓글 작성자이면 더보기 버튼을 표시하고, 수정은 작성자에게만, 삭제는 작성자 또는 게시글 크리에이터에게 허용하는 것을 확인했다. 계획 문서에 동일 권한 규칙을 반영했다. +- 2026-07-07: 사용자 확인 사항에 따라 신고 규칙도 기존 커뮤니티 댓글과 동일하게 검토 대상으로 두었으나, 구현 확인 결과 기존 `ReportRequest`/`ReportType`에 community comment report identifier가 없고 기존 `CreatorCommunityCommentItemView`도 신고 action을 노출하지 않아 계획 문서에 [blocked]로 정정했다. +- 2026-07-07: `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행 결과 `OK`를 확인했다. +- 2026-07-07: `git diff --check -- docs/20260707_커뮤니티_게시글_상세 SodaLive/Sources/V2/CreatorChannel/Community SodaLive/Sources/V2/CreatorChannel/ReplyDetail SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj` 실행 결과 공백 오류 없이 통과했다. +- 2026-07-07: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행 결과 `BUILD SUCCEEDED`를 확인했다. +- 2026-07-07: `rg "canReport|onTapReport|showReportError|CreatorChannelActionPopupWithReport|private struct CreatorChannelActionPopup|신고" SodaLive/Sources/V2/CreatorChannel/Community/Detail` 실행 결과 fake report 제거 후 출력이 없음을 확인했다. +- 2026-07-07: 커뮤니티 댓글 신고 흐름은 기존 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 없어 연결하지 못했다. 구현은 기존 `CreatorCommunityCommentItemView`의 수정/삭제 popup 동작과 동일하게 맞췄고, fake report action은 노출하지 않는다. +- 2026-07-07: 정적 검증과 Debug 빌드 검증은 완료했다. Task 7.3의 device/simulator/API-backed 환경 수동 runtime QA는 아직 수행하지 않았다. diff --git a/docs/20260707_커뮤니티_게시글_상세/prd.md b/docs/20260707_커뮤니티_게시글_상세/prd.md new file mode 100644 index 00000000..a3f3611d --- /dev/null +++ b/docs/20260707_커뮤니티_게시글_상세/prd.md @@ -0,0 +1,289 @@ +# PRD: 커뮤니티 게시글 상세 + +## 1. Overview +크리에이터 채널 커뮤니티 게시글을 터치했을 때 진입하는 V2 커뮤니티 게시글 상세 화면을 제공한다. 화면은 게시글 본문, 이미지/오디오/유료 잠금 상태, 좋아요, 댓글 목록, 최신 답글, 하단 고정 댓글 입력 바를 표시한다. + +상세 API는 `GET /api/v2/creator-channels/community-posts/{postId}`를 사용한다. 댓글 추가 조회는 `GET /api/v2/creator-channels/community-posts/{postId}/comments`, 답글 추가 조회는 `GET /api/v2/creator-channels/community-comments/{commentId}/replies`를 사용한다. `page` 기본값은 `0`, `size` 기본값은 `20`이다. + +Figma 참조: +- 댓글 가능 상세: `290:9243`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9243&m=dev` +- 댓글 불가능 상세: `290:9266`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9266&m=dev` + +## 2. Problem +- 현재 V2 커뮤니티 탭은 게시글 목록을 제공하지만, 게시글 상세 API와 Figma 기준 상세 화면이 연결되어 있지 않다. +- 기존 커뮤니티 댓글 화면은 V1 sheet 기반이고, 이번 상세 화면의 하단 고정 입력 바와 댓글/최신 답글 노출 구조와 다르다. +- 팬Talk/댓글 답글 상세 UI가 이미 V2에 구현되어 있으므로 같은 UI를 복사할지 재사용할지 결정이 필요하다. +- 신규 상세/댓글/답글 조회 API는 `page=0` 기준인 반면 기존 V1 커뮤니티 API 일부는 내부에서 `page - 1`을 사용하므로 API 계층을 분리해야 한다. + +## 3. Goals +- 커뮤니티 목록 item tap 시 게시글 상세 화면으로 진입한다. +- 상세 진입 시 `GET /api/v2/creator-channels/community-posts/{postId}`를 호출한다. +- 상세 응답의 `comments`를 초기 댓글 목록으로 표시한다. +- 댓글 목록 추가 조회는 `GET /api/v2/creator-channels/community-posts/{postId}/comments?page={page}&size={size}`를 사용한다. +- 댓글의 답글 추가 조회는 `GET /api/v2/creator-channels/community-comments/{commentId}/replies?page={page}&size={size}`를 사용한다. +- 댓글 작성, 답글 작성, 댓글 수정, 댓글 삭제, 좋아요 API는 기존 커뮤니티 구현에서 사용하던 API를 그대로 사용한다. +- 댓글 popup menu는 기존 커뮤니티 댓글과 동일한 수정/삭제 흐름을 사용한다. +- 커뮤니티 댓글 신고는 [blocked] 상태로 구현 범위에서 제외한다. 현재 코드베이스에는 커뮤니티 댓글 신고용 `ReportRequest`/`ReportType` 식별자와 API가 없고, 기존 `CreatorCommunityCommentItemView`도 댓글 신고 UI를 노출하지 않는다. +- 댓글의 답글 상세 UI는 팬Talk 답글 상세와 동일하므로 `CreatorChannelReplyDetailView` 계열을 복사하지 않고 재사용/확장한다. +- `isCommentAvailable == false`이면 reaction bar의 댓글 icon/count, 댓글 목록, 하단 댓글 입력 바를 숨긴다. +- 하단 댓글 입력 바는 화면 하단에 고정하고 키보드 표시 시 키보드 위로 밀려 올라와야 한다. +- 댓글 입력창은 최대 3줄까지 자동 확장하고, 4줄 이상은 3줄 높이를 유지한 채 내부 vertical scroll로 처리한다. +- 좋아요 상태에 따라 `ic_feed_community_heart_fill` 또는 `ic_feed_community_heart`를 표시하고 tap 시 기존 좋아요 API를 호출한다. + +## 4. Non-Goals +- 커뮤니티 게시글 작성/수정 화면을 변경하지 않는다. +- 댓글/답글 작성, 수정, 삭제, 좋아요 mutation API를 신규 V2 endpoint로 교체하지 않는다. +- 기존 V1 커뮤니티 댓글 화면 전체를 삭제하거나 일괄 리팩터링하지 않는다. +- 팬Talk 답글 상세 UI를 별도 파일로 동일 복사하지 않는다. +- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. +- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. + +## 5. Target Users +- 크리에이터의 커뮤니티 게시글을 상세로 읽고 좋아요를 누르려는 사용자 +- 커뮤니티 게시글에 댓글을 작성하려는 사용자 +- 커뮤니티 댓글에 답글을 확인하거나 작성하려는 사용자 + +## 6. User Stories +- 사용자는 커뮤니티 목록에서 게시글을 터치해 상세 본문, 이미지, 좋아요 수, 댓글 수를 확인할 수 있다. +- 사용자는 댓글 입력창에 글자를 입력하면 전송 버튼이 활성화되는 것을 확인하고 댓글을 작성할 수 있다. +- 사용자는 댓글이 불가능한 게시글에서는 댓글 목록과 댓글 입력창이 보이지 않는 것을 확인할 수 있다. +- 사용자는 댓글에 최신 답글이 있으면 댓글 아래에서 답글 요약을 확인하고, 댓글을 터치해 답글 상세 화면으로 이동할 수 있다. +- 사용자는 게시글 하트를 터치해 좋아요 상태를 변경할 수 있다. + +## 7. Core Requirements + +### 7.1 API +신규 V2 조회 API는 `SodaLive/Sources/V2/CreatorChannel/Community/**` 하위 Repository/API에 추가한다. 기존 `CreatorCommunityApi`는 mutation 재사용 대상으로 유지하고, 신규 V2 조회 API에는 `page - 1` 보정을 적용하지 않는다. + +상세: +- Method: `GET` +- Path: `/api/v2/creator-channels/community-posts/{postId}` +- Path parameter: `postId` + +댓글: +- Method: `GET` +- Path: `/api/v2/creator-channels/community-posts/{postId}/comments` +- Path parameter: `postId` +- Query parameters: `page` 기본값 `0`, `size` 기본값 `20` + +답글: +- Method: `GET` +- Path: `/api/v2/creator-channels/community-comments/{commentId}/replies` +- Path parameter: `commentId` +- Query parameters: `page` 기본값 `0`, `size` 기본값 `20` + +응답 래퍼는 기존 V2 패턴대로 `ApiResponse<...>`로 디코딩한다. Kotlin `Long`은 기존 V2 관례대로 Swift `Int`로 선언한다. + +```kotlin +data class CreatorChannelCommunityPostDetailResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean, + @JsonProperty("isLiked") + val isLiked: Boolean, + val comments: CreatorChannelCommunityCommentsResponse +) + +data class CreatorChannelCommunityCommentsResponse( + val commentCount: Int, + val comments: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +data class CreatorChannelCommunityCommentResponse( + val commentId: Long, + val writerId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String, + val latestReply: CreatorChannelCommunityReplyResponse? +) + +data class CreatorChannelCommunityReplyResponse( + val commentId: Long, + val writerId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String +) + +data class CreatorChannelCommunityRepliesResponse( + val replyCount: Int, + val replies: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) +``` + +### 7.2 Entry and navigation +- `CreatorChannelCommunityTabView`의 리스트형/썸네일형 item tap에서 상세 화면으로 진입한다. +- 상세 route는 `AppStep`에 추가하고 `postId`와 필요한 refresh callback을 전달한다. +- 상세 화면에서 back tap 시 이전 화면으로 돌아간다. +- 상세에서 좋아요/댓글 작성/댓글 삭제 등으로 목록 갱신이 필요한 경우, 상세 종료 또는 mutation 성공 시 커뮤니티 탭 첫 페이지 갱신 callback을 호출한다. + +### 7.3 Screen layout +- 배경은 `Color.black`을 사용한다. +- 상단 title bar는 Figma처럼 title text 없이 back chevron만 표시한다. +- 게시글 영역은 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/오디오/잠금 영역, 좋아요/댓글 count를 표시한다. +- `createdAtUtc`는 기존 `DateParser.relativeTimeText(fromUTC:fallback:now:)` 계열 helper로 상대 시간으로 표시한다. +- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 잠금 상태로 표시한다. +- 유료 미구매 잠금 상태는 Figma처럼 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다. +- 구매 완료 상태는 Figma `290:9266`처럼 `구매완료` capsule tag를 표시한다. +- 이미지가 있고 접근 가능한 게시글이면 이미지 또는 GIF를 표시한다. +- `audioUrl`이 있으면 기존 커뮤니티 오디오 재생 흐름을 재사용해 이미지 중앙에 재생 버튼을 표시한다. +- `isPinned == true`이면 기존 커뮤니티 탭의 pinned 표시 규칙을 따른다. + +### 7.4 Reaction +- `isLiked == true`이면 `ic_feed_community_heart_fill`을 표시한다. +- `isLiked == false`이면 `ic_feed_community_heart`를 표시한다. +- 하트 tap 시 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 호출한다. +- 좋아요 tap 직후 UI는 기존 `CreatorChannelCommunityListItem`처럼 local optimistic state로 반영하고, API 실패 시 toast를 표시한 뒤 tap 직전의 `isLiked`, `likeCount` 값으로 local state를 복구한다. +- `isCommentAvailable == true`이면 댓글 icon과 `commentCount`를 표시한다. +- `isCommentAvailable == false`이면 댓글 icon/count, 댓글 목록, 하단 입력 바를 모두 숨긴다. + +### 7.5 Comments +- 상세 API 응답의 `comments.comments`를 초기 댓글 목록으로 표시한다. +- 댓글 section header는 Figma처럼 `댓글 {commentCount}` 형식으로 표시한다. +- 댓글 row는 작성자 프로필, 닉네임, 상대 시간, 본문, 우측 더보기 버튼을 표시한다. +- `writerId`로 내 댓글/답글 여부를 판정하고, 게시글의 `creatorId`로 현재 사용자가 해당 커뮤니티 게시글을 쓴 크리에이터인지 판정한다. +- 댓글/답글 popup menu는 기존 커뮤니티 댓글과 동일하게 내 댓글/답글이면 `수정하기`, `삭제하기`를 표시하고, 해당 게시글을 쓴 크리에이터이면 내가 쓴 댓글/답글이 아니어도 `삭제하기`를 표시한다. +- 커뮤니티 댓글 신고는 [blocked] 상태로 구현하지 않는다. 현재 코드베이스에는 커뮤니티 댓글 신고용 `ReportRequest`/`ReportType` 식별자와 API가 없고, 기존 `CreatorCommunityCommentItemView`도 댓글 신고 UI를 노출하지 않는다. +- `latestReply != nil`이면 댓글 아래에 최신 답글 카드와 좌측 연결선을 표시한다. +- 댓글 목록 하단 도달 시 `hasNext == true`이면 다음 page를 append한다. +- 첫 페이지 재조회 시 기존 댓글 목록을 교체한다. +- 댓글을 터치하면 답글 상세 화면으로 진입한다. + +### 7.6 Reply detail reuse decision +- 댓글의 답글 상세 UI는 팬Talk 상세와 동일한 UI를 가진다. +- 별도 동일 복사는 하지 않는다. +- 기존 `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift`, `CreatorChannelReplyDetailFeedView.swift`, `CreatorChannelReplyDetailInputBar.swift`를 재사용한다. +- 신규 응답 모델이 기존 `GetCommunityPostCommentListItem`과 필드가 다르므로, 구현 시 `CreatorChannelReplyDetailDisplayItem` 생성용 adapter 또는 context 확장을 추가한다. +- 답글 목록 추가 조회는 신규 `GET /api/v2/creator-channels/community-comments/{commentId}/replies`를 우선 사용한다. +- 답글 작성/수정/삭제 mutation은 사용자 요구대로 기존 커뮤니티 댓글 API를 재사용한다. + +### 7.7 Comment input bar +- `isCommentAvailable == true`일 때만 하단 고정 댓글 입력 바를 표시한다. +- placeholder는 사용자 요구사항 기준 `댓글을 입력하세요`를 표시한다. +- Figma에는 `댓글을 입력하세요.`처럼 마침표가 보이나, 구현 기준은 사용자 요구 문구를 우선한다. +- 댓글 쓰기 UI에는 기존 커뮤니티 댓글과 동일한 비밀댓글 toggle을 추가한다. +- 비밀댓글 toggle은 기존 커뮤니티 댓글 sheet의 `isShowSecret` 노출 정책을 따른다. +- 비밀댓글 toggle이 노출되지 않는 경우 `isSecret == false`로 댓글 작성 API를 호출한다. +- 입력값이 없거나 공백/줄바꿈만 있으면 전송 버튼을 비활성화한다. +- 입력값이 있으면 전송 버튼을 활성화한다. +- 입력창은 1줄에서 시작하고 3줄까지 자동 확장한다. +- 4줄 이상 입력 시 3줄 높이를 유지하고 입력창 내부에서 vertical scroll 처리한다. +- 하단 입력 바는 키보드가 표시되면 키보드 위로 올라와야 한다. +- 전송 중에는 중복 tap으로 같은 댓글이 여러 번 등록되지 않도록 한다. +- 댓글 작성 성공 후 입력값을 비우고 댓글 첫 페이지 또는 상세 데이터를 재조회한다. + +전송 버튼 상태: +- 비활성화: icon `ic_arrow_up_gray`, 원형 배경, `gray900` +- 활성화: icon `ic_arrow_up_white`, 원형 배경, `soda400` + +### 7.8 Comment popup menu and mutation reuse +- 댓글/답글 popup menu는 기존 커뮤니티 댓글과 동일한 메뉴 정책을 따른다. +- 기존 메뉴 정책은 `CreatorCommunityCommentItemView`와 동일하게 수정은 작성자에게만 허용하고, 삭제는 작성자 또는 게시글 크리에이터에게 허용한다. +- 커뮤니티 댓글 신고는 [blocked] 상태로 구현하지 않는다. `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 추가되기 전까지 작성자도 게시글 크리에이터도 아닌 사용자에게도 `신고`를 제공하지 않는다. +- 댓글 작성은 기존 `CreatorCommunityRepository.createCommunityPostComment(comment:postId:parentId:isSecret:)`를 사용하고, 원댓글 작성 시 `parentId == nil`, `isSecret == 비밀댓글 toggle 값`으로 호출한다. +- 답글 작성은 같은 API에 `parentId == commentId`를 전달한다. +- 댓글/답글 수정과 삭제는 기존 `CreatorCommunityRepository.modifyComment(request:)`를 사용한다. +- 좋아요는 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 사용한다. +- 댓글 신고가 필요한 경우 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 추가된 뒤 별도 범위에서 연결한다. + +### 7.9 Pagination and state +- 상세 첫 진입은 상세 API 1회로 게시글과 초기 댓글을 함께 구성한다. +- 댓글 추가 조회는 `comments.page + 1` 기준으로 호출한다. +- 답글 상세 추가 조회는 `replies.page + 1` 기준으로 호출한다. +- 조회 중 중복 호출을 막는다. +- `hasNext == false`이면 추가 조회하지 않는다. +- pull-to-refresh가 필요하면 상세 API를 다시 호출해 게시글과 댓글 첫 페이지를 동기화한다. + +## 8. Reusable V2 Candidates +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift` + - 게시글 header, content, 유료 잠금 overlay, 오디오 재생, 좋아요/댓글 reaction 규칙을 상세 게시글 영역 구현 후보로 검토한다. + - 목록 카드 배경/radius는 상세 화면과 다르므로 전체 View 복사보다 내부 규칙을 추출하거나 참고하는 방향이 적합하다. +- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift` + - 유료 잠금 가격 overlay와 pinned 표시 규칙 참고 후보이다. +- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` + - 홈/추천 카드의 좋아요 optimistic state, 잠금 overlay, 오디오 재생 흐름 참고 후보이다. +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift` + - 댓글의 답글 상세 화면 재사용 대상이다. +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailFeedView.swift` + - 원댓글/답글 카드/좌측 연결선/더보기 popup 위치 계산을 재사용한다. +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailInputBar.swift` + - 답글 입력 바의 send 상태와 하단 고정 구조를 참고하되, 댓글 상세 입력 바는 3줄 자동 확장 요구가 있어 별도 확장 또는 공용화가 필요하다. +- `SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkListItem.swift` + - 댓글 row + latest reply 카드 + 좌측 연결선 구조가 Figma 댓글 row와 유사하다. +- `SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkActionPopup.swift` + - `CreatorChannelActionPopup`이 수정/삭제 popup 공용 기반으로 이미 사용 중이다. +- `SodaLive/Sources/V2/Component/Modal/SodaV2ActionModal.swift` + - 댓글/답글 삭제 확인 dialog 후보이다. +- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailTitleBar.swift` + - back-only title bar 재사용 후보이다. + +## 9. Success Criteria +- 커뮤니티 게시글 item tap 시 상세 화면으로 진입한다. +- 상세 진입 시 `GET /api/v2/creator-channels/community-posts/{postId}`가 호출된다. +- 상세 API 응답으로 게시글 본문, 이미지/오디오/잠금 상태, 좋아요 수, 댓글 수, 초기 댓글 목록이 표시된다. +- 댓글 추가 로딩 시 `GET /api/v2/creator-channels/community-posts/{postId}/comments?page={page}&size=20`가 호출된다. +- 댓글의 답글 상세 추가 로딩 시 `GET /api/v2/creator-channels/community-comments/{commentId}/replies?page={page}&size=20`가 호출된다. +- `isCommentAvailable == false`이면 reaction bar의 댓글 icon/count, 댓글 section, 하단 댓글 입력 바가 표시되지 않는다. +- 댓글 쓰기 UI에는 기존 비밀댓글 toggle이 표시 조건에 맞게 제공된다. +- 입력값이 없으면 댓글 전송 버튼은 `gray900` 배경과 `ic_arrow_up_gray` icon으로 비활성화된다. +- 입력값이 있으면 댓글 전송 버튼은 `soda400` 배경과 `ic_arrow_up_white` icon으로 활성화된다. +- 댓글 입력창은 3줄까지 높이가 늘어나고, 4줄 이상은 3줄 높이에서 내부 스크롤된다. +- 키보드가 올라오면 하단 댓글 입력 바가 키보드 위로 올라온다. +- 댓글 작성 성공 후 입력값이 비워지고 댓글 목록이 갱신된다. +- 하트 tap 시 기존 좋아요 API가 호출되고 `isLiked` 상태에 맞는 heart icon이 표시된다. +- 댓글 popup menu는 기존 커뮤니티 댓글과 동일한 수정/삭제 흐름을 사용한다. +- 게시글을 쓴 크리에이터는 내가 작성하지 않은 댓글/답글에도 `삭제하기`를 사용할 수 있다. +- 커뮤니티 댓글 신고는 [blocked] 상태이며, 현재 코드베이스에 커뮤니티 댓글 신고용 `ReportRequest`/`ReportType` 식별자와 API가 없고 기존 `CreatorCommunityCommentItemView`도 신고를 노출하지 않으므로 이번 구현의 성공 기준에서 제외한다. +- 댓글의 답글 상세 UI는 팬Talk 상세와 동일한 기존 `CreatorChannelReplyDetailView` 계열을 재사용한다. + +## 10. Technical Constraints +- 기능 변경은 `SodaLive/Sources/V2/**` 하위에서 우선 해결한다. +- 커뮤니티 게시글 상세 신규 View, ViewModel, 모델, 조회 Repository/API는 `SodaLive/Sources/V2/CreatorChannel/Community/**` 하위에 둔다. +- 댓글/답글 mutation은 기존 `SodaLive/Sources/Explorer/Profile/CreatorCommunity/**` repository를 호출할 수 있다. +- 신규 공용 컴포넌트가 필요하면 `SodaLive/Sources/V2/Component/**` 아래에 둔다. +- 특정 상세 화면에서만 쓰는 컴포넌트는 `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/**` 아래에 둔다. +- 신규 문구가 필요하면 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja를 추가한다. +- 프로젝트 설정 변경은 신규 Swift 파일 target 등록이 필요한 경우에만 수행한다. + +## 11. Assumptions +- 상세 API의 `postId`는 기존 좋아요/댓글 작성 mutation API가 요구하는 post id와 동일하다. +- 신규 답글 API의 `commentId`는 기존 `modifyComment`와 `createCommunityPostComment(parentId:)`에서 사용하는 comment id와 동일하다. +- `existOrdered == true`이면 유료 게시글의 구매 완료 또는 접근 가능 상태로 본다. +- 본인 게시글 판정은 기존 관례대로 `creatorId == UserDefaults.int(forKey: .userId)`를 우선 사용한다. +- 댓글 원문과 최신 답글은 서버 응답 문자열을 그대로 표시한다. + +## 12. Open Questions +- 해당 없음 + +## 13. Verification Notes +- 2026-07-07: Figma `get_design_context`와 `get_screenshot`으로 `290:9243`, `290:9266`을 확인했다. +- 2026-07-07: `docs/agent-guides/documentation-policy.md`와 `docs/prd/sample-prd.md`를 확인해 신규 PRD 경로와 필수 섹션을 맞췄다. +- 2026-07-07: 기존 문서 `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md`, `docs/20260704_크리에이터_채널_팬Talk_탭/prd.md`, `docs/20260707_글_상세_답글_쓰기/prd.md`를 확인했다. +- 2026-07-07: 기존 V2 재사용 후보로 `CreatorChannelCommunityListItem`, `CreatorChannelCommunityGridItem`, `CommunityPostCard`, `CreatorChannelReplyDetailView`, `CreatorChannelReplyDetailFeedView`, `CreatorChannelReplyDetailInputBar`, `CreatorChannelFanTalkListItem`, `CreatorChannelActionPopup`, `SodaV2ActionModal`, `CreatorChannelReplyDetailTitleBar`를 확인했다. +- 2026-07-07: 기존 mutation 재사용 후보로 `CreatorCommunityRepository.communityPostLike`, `createCommunityPostComment`, `modifyComment`, `getCommunityPostDetail`, `getCommunityPostCommentList`, `getCommentReplyList`를 확인했다. 신규 V2 조회 API는 path/page 기준이 달라 V2 API에 별도 추가가 필요하다. +- 2026-07-07: 사용자 확인 사항을 반영해 댓글/답글 응답에 `writerId`를 추가하고, 댓글 쓰기 UI의 기존 비밀댓글 toggle 유지, 전송 버튼 icon의 `ic_arrow_up_gray`/`ic_arrow_up_white` 매핑, 댓글 불가능 게시글의 댓글 icon/count 숨김 정책을 확정했다. +- 2026-07-07: 기존 커뮤니티 댓글과 동일하게 수정은 작성자에게만, 삭제는 작성자 또는 게시글 크리에이터에게 제공하는 권한 규칙을 반영했다. 커뮤니티 댓글 신고는 기존 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 없고 기존 `CreatorCommunityCommentItemView`도 신고를 노출하지 않아 [blocked]로 정정했다.