From 7b756ae4bed61b8e370a9aa4222c7a7e05566298 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 7 Jul 2026 15:03:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EB=8B=B5=EA=B8=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelReplyDetailViewModel.swift | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift new file mode 100644 index 00000000..7280b396 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift @@ -0,0 +1,416 @@ +import Foundation +import Combine +import Moya + +final class CreatorChannelReplyDetailViewModel: ObservableObject { + private let communityRepository = CreatorCommunityRepository() + 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 let communityReplyPageSize = 20 + private var communityReplyTotalCount = 0 + private var isCommunityReplyLast = false + private let fanTalkReplyPageSize = 20 + + @Published var replyText = "" + @Published var parentItem: CreatorChannelReplyDetailDisplayItem? + @Published var replies = [CreatorChannelReplyDetailDisplayItem]() + @Published var isLoading = false + @Published var isLoadingNextPage = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var selectedActionItem: CreatorChannelReplyDetailDisplayItem? + @Published var editingItem: CreatorChannelReplyDetailDisplayItem? + @Published var deletingItem: CreatorChannelReplyDetailDisplayItem? + @Published var isShowDeleteDialog = false + + var isSendEnabled: Bool { + replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + var isEditing: Bool { + editingItem != nil + } + + func configure(context: CreatorChannelReplyDetailContext, onFanTalkRefresh: (() -> Void)?) { + guard self.context == nil else { return } + + self.context = context + self.onFanTalkRefresh = onFanTalkRefresh + + switch context { + case .community(_, _, let parentComment): + parentItem = CreatorChannelReplyDetailDisplayItem(communityComment: parentComment) + fetchCommunityReplies(reset: true) + case .fanTalk(let creatorId, let parentFanTalk): + parentItem = CreatorChannelReplyDetailDisplayItem(fanTalk: parentFanTalk) + replies = parentFanTalk.creatorReplies.map { + CreatorChannelReplyDetailDisplayItem(fanTalkReply: $0, creatorId: creatorId) + } + } + } + + func canEdit(_ item: CreatorChannelReplyDetailDisplayItem) -> Bool { + switch item.source { + case .community: + return item.isMine + case .fanTalk: + guard item.id > 0 else { return false } + return item.isMine || item.isCreatorReply + } + } + + func canDelete(_ item: CreatorChannelReplyDetailDisplayItem) -> Bool { + switch item.source { + case .community: + if item.isMine { return true } + if case .community(let creatorId, _, _) = context { + return creatorId == UserDefaults.int(forKey: .userId) + } + return false + case .fanTalk: + guard item.id > 0 else { return false } + if item.isMine || item.isCreatorReply { return true } + if case .fanTalk(let creatorId, _) = context { + return creatorId == UserDefaults.int(forKey: .userId) + } + return false + } + } + + func selectActionItem(_ item: CreatorChannelReplyDetailDisplayItem) { + selectedActionItem = selectedActionItem?.id == item.id ? nil : item + } + + func beginEditing(item: CreatorChannelReplyDetailDisplayItem) { + guard canEdit(item) else { return } + selectedActionItem = nil + editingItem = item + replyText = item.content + } + + func cancelEditing() { + editingItem = nil + replyText = "" + } + + func presentDeleteDialog(item: CreatorChannelReplyDetailDisplayItem) { + guard canDelete(item) else { return } + selectedActionItem = nil + deletingItem = item + isShowDeleteDialog = true + } + + func send() { + guard isSendEnabled, isLoading == false else { return } + + if let editingItem { + modify(item: editingItem) + } else { + createReply() + } + } + + func confirmDelete() { + guard let deletingItem, isLoading == false else { return } + isShowDeleteDialog = false + + switch deletingItem.source { + case .community: + deleteCommunity(item: deletingItem) + case .fanTalk: + deleteFanTalk(item: deletingItem) + } + } + + func fetchCommunityReplies(reset: Bool = false) { + guard case .community(_, _, let parentComment) = context else { return } + guard isLoading == false, isLoadingNextPage == false else { return } + + if reset { + communityReplyPage = 1 + communityReplyTotalCount = 0 + isCommunityReplyLast = false + } else if isCommunityReplyLast { + return + } + + if reset { + isLoading = true + } else { + isLoadingNextPage = true + } + + let requestPage = communityReplyPage + communityRepository.getCommentReplyList(commentId: parentComment.id, page: requestPage, size: communityReplyPageSize) + .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 + guard let self else { return } + 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 + } + } + .store(in: &subscription) + } + + func fetchNextCommunityRepliesIfNeeded(currentItem: CreatorChannelReplyDetailDisplayItem) { + guard currentItem == replies.last else { return } + fetchCommunityReplies() + } + + private func createReply() { + let content = replyText.trimmingCharacters(in: .whitespacesAndNewlines) + + switch context { + case .community(_, let postId, let parentComment): + isLoading = true + communityRepository.createCommunityPostComment( + comment: content, + postId: postId, + 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 .fanTalk(let creatorId, let parentFanTalk): + isLoading = true + explorerRepository.writeCheers(parentCheersId: parentFanTalk.fanTalkId, creatorId: creatorId, content: content) + .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?.handleFanTalkMutationResponse(response) + } + .store(in: &subscription) + case .none: + return + } + } + + private func modify(item: CreatorChannelReplyDetailDisplayItem) { + let content = replyText.trimmingCharacters(in: .whitespacesAndNewlines) + guard content.isEmpty == false, content != item.content else { + errorMessage = I18n.Explorer.noChanges + isShowPopup = true + return + } + + switch item.source { + case .community: + isLoading = true + var request = ModifyCommunityPostCommentRequest(commentId: item.id) + 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?.handleCommunityMutationResponse(response) + } + .store(in: &subscription) + case .fanTalk: + guard item.id > 0 else { return } + isLoading = true + explorerRepository.modifyCheers(cheersId: item.id, content: content, isActive: nil) + .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?.handleFanTalkMutationResponse(response) + } + .store(in: &subscription) + } + } + + private func deleteCommunity(item: CreatorChannelReplyDetailDisplayItem) { + isLoading = true + var request = ModifyCommunityPostCommentRequest(commentId: item.id) + 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?.handleCommunityMutationResponse(response) + } + .store(in: &subscription) + } + + private func deleteFanTalk(item: CreatorChannelReplyDetailDisplayItem) { + guard item.id > 0 else { return } + isLoading = true + explorerRepository.modifyCheers(cheersId: item.id, content: nil, isActive: 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?.handleFanTalkMutationResponse(response) + } + .store(in: &subscription) + } + + + private func fetchFanTalkReplies(page: Int = 0) { + guard case .fanTalk(let creatorId, let parentFanTalk) = context else { return } + + fanTalkRepository.getFanTalks(creatorId: creatorId, page: page, size: fanTalkReplyPageSize) + .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 + guard let self else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + if let fanTalk = data.fanTalks.first(where: { $0.fanTalkId == parentFanTalk.fanTalkId }) { + self.isLoading = false + self.replies = fanTalk.creatorReplies.map { + CreatorChannelReplyDetailDisplayItem(fanTalkReply: $0, creatorId: creatorId) + } + } else if data.hasNext { + self.fetchFanTalkReplies(page: page + 1) + } else { + self.isLoading = false + } + } else { + self.isLoading = false + self.errorMessage = decoded.message ?? I18n.Common.commonError + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.isLoading = false + self.errorMessage = I18n.Common.commonError + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + private func handleCommunityMutationResponse(_ response: Moya.Response) { + isLoading = false + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + if decoded.success { + applyLocalSuccessState() + fetchCommunityReplies(reset: true) + } else { + errorMessage = decoded.message ?? I18n.Common.commonError + isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + errorMessage = I18n.Common.commonError + isShowPopup = true + } + } + + private func handleFanTalkMutationResponse(_ response: Moya.Response) { + isLoading = false + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + if decoded.success { + applyLocalSuccessState() + onFanTalkRefresh?() + fetchFanTalkReplies() + } else { + errorMessage = decoded.message ?? I18n.Common.commonError + isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + errorMessage = I18n.Common.commonError + isShowPopup = true + } + } + + + private func applyLocalSuccessState() { + let trimmedReplyText = replyText.trimmingCharacters(in: .whitespacesAndNewlines) + + if let editingItem { + let updatedItem = editingItem.updatingContent(trimmedReplyText) + if parentItem?.id == editingItem.id && parentItem?.source == editingItem.source { + parentItem = updatedItem + } else if let index = replies.firstIndex(where: { $0.id == editingItem.id && $0.source == editingItem.source }) { + replies[index] = updatedItem + } + } + + if let deletingItem { + if parentItem?.id == deletingItem.id && parentItem?.source == deletingItem.source { + AppState.shared.back() + } else { + replies.removeAll { $0.id == deletingItem.id && $0.source == deletingItem.source } + } + } + + replyText = "" + editingItem = nil + deletingItem = nil + } + + private func applyFailureState() { + isLoading = false + errorMessage = I18n.Common.commonError + isShowPopup = true + } +}