From 20801bdcfb8af724360b663fd0c2a94dac23d6a7 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 4 Sep 2025 06:34:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-room):=20=EC=9C=A0=EB=A3=8C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B5=AC=EB=A7=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Talk/Room/ChatRoomRepository.swift | 14 +++++ .../Sources/Chat/Talk/Room/ChatRoomView.swift | 37 ++++++++++++- .../Chat/Talk/Room/ChatRoomViewModel.swift | 53 ++++++++++++++++++- .../Talk/Room/Message/AiMessageItemView.swift | 45 +++++++++++++++- .../Message/ChatMessagePurchaseRequest.swift | 10 ++++ SodaLive/Sources/Chat/Talk/TalkApi.swift | 11 ++++ 6 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 SodaLive/Sources/Chat/Talk/Room/Message/ChatMessagePurchaseRequest.swift diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift index 1f7dac1..712fe4c 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift @@ -36,6 +36,20 @@ class ChatRoomRepository { return talkApi.requestPublisher(.sendMessage(roomId: roomId, request: SendChatMessageRequest(message: message))) } + /** + * 유료 메시지 구매 + * - 성공 시 서버에서 갱신된 메시지를 반환 + */ + func purchaseMessage(roomId: Int, messageId: Int64) -> AnyPublisher { + return talkApi.requestPublisher( + .purchaseMessage( + roomId: roomId, + messageId: messageId, + request: ChatMessagePurchaseRequest() + ) + ) + } + /** 쿼터 상태 조회 */ func getChatQuotaStatus() -> AnyPublisher { return talkApi.requestPublisher(.getChatQuotaStatus) diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift index c242733..69b9db2 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift @@ -122,7 +122,10 @@ struct ChatRoomView: View { AiMessageItemView( message: message, characterName: viewModel.characterName - ) + ) { + viewModel.selectedMessage = message + viewModel.selectedMessageIndex = index + } .id(index) } } @@ -210,6 +213,21 @@ struct ChatRoomView: View { .frame(width: screenSize().width) } } + + if let message = viewModel.selectedMessage, viewModel.selectedMessageIndex >= 0 { + SodaDialog( + title: "잠금된 메시지", + desc: "이 메시지를 \(message.price ?? 5)캔으로 잠금해제 하시겠습니까?", + confirmButtonTitle: "잠금해제", + confirmButtonAction: { + viewModel.purchaseChatMessage() + }, + cancelButtonTitle: "취소" + ) { + viewModel.selectedMessage = nil + viewModel.selectedMessageIndex = -1 + } + } } .onAppear { viewModel.enterRoom(roomId: roomId) @@ -217,6 +235,23 @@ struct ChatRoomView: View { .onDisappear { viewModel.stopTimer() } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } } } diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift index 4fd5188..f0917c8 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift @@ -30,6 +30,9 @@ final class ChatRoomViewModel: ObservableObject { @Published var messageText: String = "" @Published private(set) var messages: [ServerChatMessage] = [] + @Published var selectedMessage: ServerChatMessage? = nil + @Published var selectedMessageIndex: Int = -1 + // MARK: - Private private let userRepository = UserRepository() private let repository = ChatRoomRepository() @@ -160,6 +163,54 @@ final class ChatRoomViewModel: ObservableObject { .store(in: &subscription) } + func purchaseChatMessage() { + guard let selectedMessage = selectedMessage else { + return + } + + isLoading = true + + repository.purchaseMessage(roomId: roomId, messageId: selectedMessage.messageId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [weak self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self?.messages.insert(data, at: self?.selectedMessageIndex ?? 0) + self?.messages.remove(at: (self?.selectedMessageIndex ?? 0) + 1) + + self?.selectedMessage = nil + self?.selectedMessageIndex = -1 + } else { + if let message = decoded.message { + self?.errorMessage = message + } else { + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self?.isShowPopup = true + } + + self?.isLoading = false + } catch { + self?.isLoading = false + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.isShowPopup = true + } + } + .store(in: &subscription) + } + func purchaseChatQuota() { } @@ -178,8 +229,6 @@ final class ChatRoomViewModel: ObservableObject { } receiveValue: { [weak self] response in let responseData = response.data - DEBUG_LOG(String(data: responseData, encoding: .utf8) ?? "") - do { let jsonDecoder = JSONDecoder() let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) diff --git a/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift b/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift index 9464431..cee4b52 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift @@ -64,6 +64,8 @@ struct AiMessageItemView: View { let message: ServerChatMessage let characterName: String + let purchaseMessage: () -> Void + var body: some View { HStack(alignment: .bottom, spacing: 4) { // 메시지 영역 @@ -88,7 +90,10 @@ struct AiMessageItemView: View { } // 메시지 내용 (텍스트 또는 이미지) - if message.messageType.lowercased() == "image", let imageUrl = message.imageUrl, !imageUrl.isEmpty { + if message.messageType.lowercased() == "image", + let imageUrl = message.imageUrl, + !imageUrl.isEmpty + { // 이미지 메시지 let maxWidth = (UIScreen.main.bounds.width - 48) * 0.7 let imageHeight = maxWidth * 5 / 4 // 4:5 비율 @@ -97,6 +102,41 @@ struct AiMessageItemView: View { KFImage(URL(string: imageUrl)) .resizable() .scaledToFill() // 비율 유지하며 프레임을 채움 + + Color.black.opacity(0.2) + .frame(width: maxWidth, height: imageHeight) + .cornerRadius(30) + + if let price = message.price, price > 0, !message.hasAccess { + VStack(spacing: 18) { + HStack(spacing: 4) { + Image("ic_can") + .resizable() + .frame(width: 24, height: 24) + + Text("\(message.price ?? 5)") + .font(.custom(Font.preBold.rawValue, size: 16)) + .foregroundColor(Color(hex: "263238")) + } + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background(Color(hex: "B5E7FA")) + .cornerRadius(30) + .overlay { + RoundedRectangle(cornerRadius: 30) + .stroke(lineWidth: 1) + .foregroundColor(.button) + } + + Text("눌러서 잠금해제") + .font(.custom(Font.preBold.rawValue, size: 18)) + .foregroundColor(.white) + } + .frame(width: maxWidth, height: imageHeight) + .onTapGesture { + purchaseMessage() + } + } } .frame(width: maxWidth, height: imageHeight) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) @@ -189,7 +229,8 @@ struct AiMessageItemView: View { price: nil, hasAccess: true ), - characterName: "보라" + characterName: "보라", + purchaseMessage: {} ) .padding() .background(Color.black) diff --git a/SodaLive/Sources/Chat/Talk/Room/Message/ChatMessagePurchaseRequest.swift b/SodaLive/Sources/Chat/Talk/Room/Message/ChatMessagePurchaseRequest.swift new file mode 100644 index 0000000..8948515 --- /dev/null +++ b/SodaLive/Sources/Chat/Talk/Room/Message/ChatMessagePurchaseRequest.swift @@ -0,0 +1,10 @@ +// +// ChatMessagePurchaseRequest.swift +// SodaLive +// +// Created by klaus on 9/4/25. +// + +struct ChatMessagePurchaseRequest: Encodable { + let container: String = "ios" +} diff --git a/SodaLive/Sources/Chat/Talk/TalkApi.swift b/SodaLive/Sources/Chat/Talk/TalkApi.swift index 93bc41b..fb60492 100644 --- a/SodaLive/Sources/Chat/Talk/TalkApi.swift +++ b/SodaLive/Sources/Chat/Talk/TalkApi.swift @@ -16,6 +16,8 @@ enum TalkApi { case getChatRoomMessages(roomId: Int, cursor: Int?, limit: Int) case getChatQuotaStatus + + case purchaseMessage(roomId: Int, messageId: Int64, request: ChatMessagePurchaseRequest) } extension TalkApi: TargetType { @@ -40,6 +42,9 @@ extension TalkApi: TargetType { case .getChatQuotaStatus: return "/api/chat/quota/me" + + case .purchaseMessage(let roomId, let messageId, _): + return "/api/chat/room/\(roomId)/messages/\(messageId)/purchase" } } @@ -62,6 +67,9 @@ extension TalkApi: TargetType { case .getChatQuotaStatus: return .get + + case .purchaseMessage: + return .post } } @@ -102,6 +110,9 @@ extension TalkApi: TargetType { case .getChatQuotaStatus: return .requestPlain + + case .purchaseMessage(_, _, let request): + return .requestJSONEncodable(request) } }