// // ChatRoomViewModel.swift // SodaLive // // Created by klaus on 9/2/25. // import Foundation import Combine import Moya final class ChatRoomViewModel: ObservableObject { // MARK: - Published State @Published var isLoading: Bool = false @Published var errorMessage: String = "" @Published var isShowPopup = false @Published private(set) var characterProfileUrl: String = "" @Published private(set) var characterName: String = "Character Name" @Published private(set) var characterType: CharacterType = .Character @Published private(set) var chatRoomBgImageUrl: String? = nil @Published private(set) var roomId: Int = 0 @Published private(set) var countdownText: String = "00:00:00" @Published private(set) var showQuotaNoticeView: Bool = false @Published private(set) var showSendingMessage: Bool = false // MARK: - Message State @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() private var subscription = Set() private var hasMoreMessages: Bool = true private var nextCursor: Int64? = nil private var timer: Timer? // MARK: - Actions @MainActor func sendMessage() { guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } let message = messageText.trimmingCharacters(in: .whitespacesAndNewlines) messageText = "" let nowMs = Int64(Date().timeIntervalSince1970 * 1000) messages.append( ServerChatMessage( messageId: 0 - nowMs, message: message, profileImageUrl: "", mine: true, createdAt: nowMs, messageType: "TEXT", imageUrl: nil, price: nil, hasAccess: true ) ) showSendingMessage = true repository.sendMessage(roomId: roomId, message: message) .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.append(contentsOf: data.messages) self?.updateQuota(totalRemaining: data.totalRemaining, nextRechargeAtEpoch: data.nextRechargeAtEpoch) } else { if let message = decoded.message { self?.errorMessage = message } else { self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." } self?.isShowPopup = true } self?.showSendingMessage = false } catch { self?.showSendingMessage = false self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.isShowPopup = true } } .store(in: &subscription) } @MainActor func enterRoom(roomId: Int) { isLoading = true self.roomId = roomId repository.enterChatRoom( roomId: roomId, characterImageId: getSavedBackgroundImageId() ) .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?.characterName = data.character.name self?.characterType = data.character.characterType self?.characterProfileUrl = data.character.profileImageUrl self?.chatRoomBgImageUrl = data.bgImageUrl ?? data.character.profileImageUrl self?.messages.insert(contentsOf: data.messages.sorted(), at: 0) self?.hasMoreMessages = data.hasMoreMessages self?.nextCursor = data.messages.last?.messageId self?.updateQuota(totalRemaining: data.totalRemaining, nextRechargeAtEpoch: data.nextRechargeAtEpoch) } 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 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() { } private func checkQuotaStatus() { isLoading = true repository.getChatQuotaStatus() .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?.updateQuota(totalRemaining: data.totalRemaining, nextRechargeAtEpoch: data.nextRechargeAtEpoch) } 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) } private func updateQuota(totalRemaining: Int, nextRechargeAtEpoch: Int64?) { isLoading = true stopTimer() // epoch 없음 → 카운트다운 비표시 guard let nextRechargeAtEpoch else { countdownText = "00:00:00" showQuotaNoticeView = false isLoading = false return } // 즉시 1회 갱신 let remainMs = remainingMs(to: nextRechargeAtEpoch) updateCountdownText(remainMs) // 이미 0이면 종료 처리 guard remainMs > 0 else { checkQuotaStatus() return } isLoading = false showQuotaNoticeView = true // 타이머 시작 (1초마다 갱신) startTimer(targetEpoch: nextRechargeAtEpoch) } private func updateCountdownText(_ remainMs: Int64) { countdownText = remainMs > 0 ? formatMillisToHms(remainMs) : "00:00:00" } private func startTimer(targetEpoch: Int64) { stopTimer() timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self else { return } let remain = self.remainingMs(to: targetEpoch) self.updateCountdownText(remain) if remain == 0 { self.stopTimer() self.checkQuotaStatus() } } if let t = timer { RunLoop.main.add(t, forMode: .common) } } func stopTimer() { timer?.invalidate() timer = nil } private func remainingMs(to epoch: Int64) -> Int64 { let ms = normalizeToMs(epoch) let nowMs = Int64(Date().timeIntervalSince1970 * 1000) let fudgeMs: Int64 = 5000 // Kotlin 로직과 동일하게 표시 보정 적용 return max(ms - nowMs + fudgeMs, 0) } /// 초 단위/밀리초 단위 혼용 대비 private func normalizeToMs(_ epoch: Int64) -> Int64 { epoch < 1_000_000_000_000 ? epoch * 1000 : epoch } private func formatMillisToHms(_ ms: Int64) -> String { let total = ms / 1000 let h = total / 3600 let m = (total % 3600) / 60 let s = total % 60 return String(format: "%02d:%02d:%02d", h, m, s) } private func getSavedBackgroundImageId() -> Int? { let imageId = UserDefaults.standard.integer(forKey: bgImageIdKey()) return imageId > 0 ? imageId : nil } private func bgImageIdKey() -> String { return "chat_bg_image_id_room_\(roomId)" } }