Files
sodalive-ios/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift
Yu Sung f6af20bd7e feat(chat-settings-view): 대화설정
- 배경 이미지 숨김
- 대화 초기화 기능 추가
2025-09-04 10:20:22 +09:00

502 lines
19 KiB
Swift

//
// 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 isResetting: Bool = false
@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
@Published var isShowImageViewer = false
@Published var selectedImageIndex: Int = 0
@Published var isHideBg = false {
didSet {
UserDefaults.standard.set(isHideBg, forKey: bgHideKey())
}
}
@Published var isShowingChatSettingsView = false
@Published var isShowingChangeBgView = false
@Published var isShowingChatResetConfirmDialog = false
var ownedImageUrls: [String] {
return messages
.filter { $0.hasAccess }
.filter { $0.messageType.lowercased() == "image" && $0.imageUrl != nil && !$0.imageUrl.isNullOrBlank() }
.map { $0.imageUrl! }
}
// MARK: - Private
private let userRepository = UserRepository()
private let repository = ChatRoomRepository()
private var subscription = Set<AnyCancellable>()
private var hasMoreMessages: Bool = true
private var nextCursor: Int64? = nil
private var timer: Timer?
// MARK: - Actions
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<SendChatMessageResponse>.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)
}
func enterRoom(roomId: Int) {
isLoading = true
self.roomId = roomId
self.isHideBg = UserDefaults.standard.bool(forKey: bgHideKey())
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<ChatRoomEnterResponse>.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 getMemberInfo() {
userRepository.getMemberInfo()
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetMemberInfoResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
UserDefaults.set(data.can, forKey: .can)
UserDefaults.set(data.point, forKey: .point)
}
} catch {
print(error)
}
}
.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<ServerChatMessage>.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() {
isLoading = true
repository.purchaseChatQuota()
.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<ChatQuotaStatusResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self?.updateQuota(totalRemaining: data.totalRemaining, nextRechargeAtEpoch: data.nextRechargeAtEpoch)
let can = UserDefaults.int(forKey: .can)
UserDefaults.set(can - 30, forKey: .can)
} 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 resetChatRoom() {
isResetting = true
repository.resetChatRoom(roomId: roomId)
.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
self?.isResetting = false
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<CreateChatRoomResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self?.resetData()
self?.getMemberInfo()
self?.enterRoom(roomId: data.chatRoomId)
} else {
if let message = decoded.message {
self?.errorMessage = message
} else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self?.isShowPopup = true
}
} catch {
ERROR_LOG(String(describing: error))
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self?.isShowPopup = true
}
}
.store(in: &subscription)
}
func showImageViewer(_ imageUrl: String?) {
if let imageUrl = imageUrl {
selectedImageIndex = ownedImageUrls.firstIndex(of: imageUrl) ?? 0
isShowImageViewer = true
}
}
private func resetData() {
characterProfileUrl = ""
characterName = "Character Name"
characterType = .Character
chatRoomBgImageUrl = nil
roomId = 0
countdownText = "00:00:00"
showQuotaNoticeView = false
showSendingMessage = false
messageText = ""
messages = []
selectedMessage = nil
selectedMessageIndex = -1
isShowImageViewer = false
selectedImageIndex = 0
isShowingChatSettingsView = false
isShowingChangeBgView = false
isShowingChatResetConfirmDialog = false
}
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<ChatQuotaStatusResponse>.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)"
}
private func bgHideKey() -> String {
return "chat_bg_hide_room_\(roomId)"
}
}