feat(chat-room) 채팅방 API
- 채팅방 입장 API 연동 - 채팅 쿼터가 없을 때 표시할 UI 추가
This commit is contained in:
		
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_time.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_time.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
{
 | 
			
		||||
  "images" : [
 | 
			
		||||
    {
 | 
			
		||||
      "filename" : "ic_time.png",
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "scale" : "1x"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "scale" : "2x"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "idiom" : "universal",
 | 
			
		||||
      "scale" : "3x"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "info" : {
 | 
			
		||||
    "author" : "xcode",
 | 
			
		||||
    "version" : 1
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_time.imageset/ic_time.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_time.imageset/ic_time.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 578 B  | 
@@ -35,4 +35,9 @@ class ChatRoomRepository {
 | 
			
		||||
    func sendMessage(roomId: Int, message: String) -> AnyPublisher<Response, MoyaError> {
 | 
			
		||||
        return talkApi.requestPublisher(.sendMessage(roomId: roomId, request: SendChatMessageRequest(message: message)))
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    /** 쿼터 상태 조회 */
 | 
			
		||||
    func getChatQuotaStatus() -> AnyPublisher<Response, MoyaError> {
 | 
			
		||||
        return talkApi.requestPublisher(.getChatQuotaStatus)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ struct ChatRoomView: View {
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        BaseView(isLoading: $viewModel.isLoading) {
 | 
			
		||||
            ChatRoomBgView()
 | 
			
		||||
            ChatRoomBgView(url: viewModel.chatRoomBgImageUrl)
 | 
			
		||||
            
 | 
			
		||||
            VStack(spacing: 0) {
 | 
			
		||||
                HStack(spacing: 12) {
 | 
			
		||||
@@ -38,6 +38,7 @@ struct ChatRoomView: View {
 | 
			
		||||
                        }
 | 
			
		||||
                        .resizable()
 | 
			
		||||
                        .frame(width: 36, height: 36)
 | 
			
		||||
                        .clipShape(Circle())
 | 
			
		||||
                    
 | 
			
		||||
                    VStack(alignment: .leading, spacing: 4) {
 | 
			
		||||
                        Text(viewModel.characterName)
 | 
			
		||||
@@ -116,11 +117,26 @@ struct ChatRoomView: View {
 | 
			
		||||
                                    let message = viewModel.messages[index]
 | 
			
		||||
                                    if message.mine {
 | 
			
		||||
                                        UserMessageItemView(message: message)
 | 
			
		||||
                                            .id(index)
 | 
			
		||||
                                    } else {
 | 
			
		||||
                                        AiMessageItemView(
 | 
			
		||||
                                            message: message,
 | 
			
		||||
                                            characterName: viewModel.characterName
 | 
			
		||||
                                        )
 | 
			
		||||
                                        .id(index)
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                                
 | 
			
		||||
                                if viewModel.showQuotaNoticeView {
 | 
			
		||||
                                    ChatQuotaNoticeItemView(remainingTime: viewModel.countdownText) {
 | 
			
		||||
                                        
 | 
			
		||||
                                    }
 | 
			
		||||
                                    .id(viewModel.messages.count)
 | 
			
		||||
                                    .padding(.bottom, 12)
 | 
			
		||||
                                    .onAppear {
 | 
			
		||||
                                        withAnimation(.easeOut(duration: 0.3)) {
 | 
			
		||||
                                            proxy.scrollTo(viewModel.messages.count, anchor: .bottom)
 | 
			
		||||
                                        }
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
@@ -139,65 +155,69 @@ struct ChatRoomView: View {
 | 
			
		||||
                .frame(width: screenSize().width)
 | 
			
		||||
                .frame(maxHeight: .infinity)
 | 
			
		||||
                
 | 
			
		||||
                HStack(spacing: 8) {
 | 
			
		||||
                    HStack(spacing: 0) {
 | 
			
		||||
                        ZStack(alignment: .leading) {
 | 
			
		||||
                            if viewModel.messageText.isEmpty {
 | 
			
		||||
                                Text("메시지를 입력하세요.")
 | 
			
		||||
                                    .font(.custom(Font.preRegular.rawValue, size: 14))
 | 
			
		||||
                                    .foregroundColor(Color(hex: "78909C"))
 | 
			
		||||
                            }
 | 
			
		||||
                            
 | 
			
		||||
                            TextField("", text: $viewModel.messageText)
 | 
			
		||||
                                .font(.custom(Font.preRegular.rawValue, size: 14))
 | 
			
		||||
                                .foregroundColor(.white)
 | 
			
		||||
                                .onSubmit {
 | 
			
		||||
                                    viewModel.sendMessage()
 | 
			
		||||
                if !viewModel.showQuotaNoticeView {
 | 
			
		||||
                    HStack(spacing: 8) {
 | 
			
		||||
                        HStack(spacing: 0) {
 | 
			
		||||
                            ZStack(alignment: .leading) {
 | 
			
		||||
                                if viewModel.messageText.isEmpty {
 | 
			
		||||
                                    Text("메시지를 입력하세요.")
 | 
			
		||||
                                        .font(.custom(Font.preRegular.rawValue, size: 14))
 | 
			
		||||
                                        .foregroundColor(Color(hex: "78909C"))
 | 
			
		||||
                                }
 | 
			
		||||
                                
 | 
			
		||||
                                TextField("", text: $viewModel.messageText)
 | 
			
		||||
                                    .font(.custom(Font.preRegular.rawValue, size: 14))
 | 
			
		||||
                                    .foregroundColor(.white)
 | 
			
		||||
                                    .onSubmit {
 | 
			
		||||
                                        viewModel.sendMessage()
 | 
			
		||||
                                    }
 | 
			
		||||
                            }
 | 
			
		||||
                            .frame(maxWidth: .infinity)
 | 
			
		||||
                        }
 | 
			
		||||
                        .padding(.horizontal, 16)
 | 
			
		||||
                        .padding(.vertical, 13)
 | 
			
		||||
                        .background(Color(hex: "263238"))
 | 
			
		||||
                        .cornerRadius(999)
 | 
			
		||||
                        .overlay(
 | 
			
		||||
                            RoundedRectangle(cornerRadius: 999)
 | 
			
		||||
                                .stroke(Color(hex: "263238"), lineWidth: 1)
 | 
			
		||||
                        )
 | 
			
		||||
                        
 | 
			
		||||
                        Button(action: {
 | 
			
		||||
                            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
 | 
			
		||||
                            viewModel.sendMessage()
 | 
			
		||||
                        }) {
 | 
			
		||||
                            Image("ic_message_send")
 | 
			
		||||
                                .resizable()
 | 
			
		||||
                                .frame(width: 24, height: 24)
 | 
			
		||||
                        }
 | 
			
		||||
                        .frame(maxWidth: .infinity)
 | 
			
		||||
                    }
 | 
			
		||||
                    .padding(.horizontal, 16)
 | 
			
		||||
                    .padding(.vertical, 13)
 | 
			
		||||
                    .background(Color(hex: "263238"))
 | 
			
		||||
                    .cornerRadius(999)
 | 
			
		||||
                    .overlay(
 | 
			
		||||
                        RoundedRectangle(cornerRadius: 999)
 | 
			
		||||
                            .stroke(Color(hex: "263238"), lineWidth: 1)
 | 
			
		||||
                    )
 | 
			
		||||
                    
 | 
			
		||||
                    Button(action: {
 | 
			
		||||
                        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
 | 
			
		||||
                        viewModel.sendMessage()
 | 
			
		||||
                    }) {
 | 
			
		||||
                        Image("ic_message_send")
 | 
			
		||||
                            .resizable()
 | 
			
		||||
                            .frame(width: 24, height: 24)
 | 
			
		||||
                    }
 | 
			
		||||
                    .padding(.horizontal, 12)
 | 
			
		||||
                    .padding(.vertical, 12)
 | 
			
		||||
                    .frame(width: screenSize().width)
 | 
			
		||||
                }
 | 
			
		||||
                .padding(.horizontal, 12)
 | 
			
		||||
                .padding(.vertical, 12)
 | 
			
		||||
                .frame(width: screenSize().width)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        .onAppear {
 | 
			
		||||
            viewModel.enterRoom(roomId: roomId)
 | 
			
		||||
        }
 | 
			
		||||
        .onDisappear {
 | 
			
		||||
            viewModel.stopTimer()
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ChatRoomBgView: View {
 | 
			
		||||
    
 | 
			
		||||
    let url: String? = nil
 | 
			
		||||
    let url: String?
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        ZStack {
 | 
			
		||||
            if let url = url {
 | 
			
		||||
                KFImage(URL(string: url))
 | 
			
		||||
                    .resizable()
 | 
			
		||||
                    .scaledToFill()
 | 
			
		||||
                    .ignoresSafeArea()
 | 
			
		||||
            } else {
 | 
			
		||||
                Image("img_sample")
 | 
			
		||||
                    .resizable()
 | 
			
		||||
                    .scaledToFill()
 | 
			
		||||
                    .aspectRatio(4/5, contentMode: .fill)
 | 
			
		||||
                    .frame(maxWidth: screenSize().width)
 | 
			
		||||
                    .ignoresSafeArea()
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,11 @@ final class ChatRoomViewModel: ObservableObject {
 | 
			
		||||
    @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
 | 
			
		||||
    
 | 
			
		||||
    // MARK: - Message State
 | 
			
		||||
    @Published var messageText: String = ""
 | 
			
		||||
@@ -28,6 +33,11 @@ final class ChatRoomViewModel: ObservableObject {
 | 
			
		||||
    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
 | 
			
		||||
    @MainActor
 | 
			
		||||
    func sendMessage() {
 | 
			
		||||
@@ -41,4 +51,188 @@ final class ChatRoomViewModel: ObservableObject {
 | 
			
		||||
        // TODO: 실제 메시지 전송 로직 구현
 | 
			
		||||
        DEBUG_LOG("메시지 전송: \(message)")
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    @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<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 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
 | 
			
		||||
                
 | 
			
		||||
                DEBUG_LOG(String(data: responseData, encoding: .utf8) ?? "")
 | 
			
		||||
                
 | 
			
		||||
                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)"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@
 | 
			
		||||
//  Created by klaus on 9/2/25.
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
struct ServerChatMessage: Decodable {
 | 
			
		||||
struct ServerChatMessage: Decodable, Comparable {
 | 
			
		||||
    let messageId: Int64
 | 
			
		||||
    let message: String
 | 
			
		||||
    let profileImageUrl: String
 | 
			
		||||
@@ -15,4 +15,12 @@ struct ServerChatMessage: Decodable {
 | 
			
		||||
    let imageUrl: String?
 | 
			
		||||
    let price: Int?
 | 
			
		||||
    let hasAccess: Bool
 | 
			
		||||
    
 | 
			
		||||
    static func < (lhs: ServerChatMessage, rhs: ServerChatMessage) -> Bool {
 | 
			
		||||
        if lhs.createdAt == rhs.createdAt {
 | 
			
		||||
            return lhs.messageId < rhs.messageId
 | 
			
		||||
        } else {
 | 
			
		||||
            return lhs.createdAt < rhs.createdAt
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,11 +8,58 @@
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct ChatQuotaNoticeItemView: View {
 | 
			
		||||
    
 | 
			
		||||
    let remainingTime: String
 | 
			
		||||
    let purchase: () -> Void
 | 
			
		||||
    
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
 | 
			
		||||
        VStack(spacing: 10) {
 | 
			
		||||
            VStack(spacing: 8) {
 | 
			
		||||
                Image("ic_time")
 | 
			
		||||
                    .resizable()
 | 
			
		||||
                    .frame(width: 30, height: 30)
 | 
			
		||||
                
 | 
			
		||||
                Text(remainingTime)
 | 
			
		||||
                    .font(.custom(Font.preBold.rawValue, size: 18))
 | 
			
		||||
                    .foregroundColor(.white)
 | 
			
		||||
                
 | 
			
		||||
                Text("기다리면 무료 이용이 가능합니다.")
 | 
			
		||||
                    .font(.custom(Font.preBold.rawValue, size: 18))
 | 
			
		||||
                    .foregroundColor(.white)
 | 
			
		||||
            }
 | 
			
		||||
            .frame(maxWidth: .infinity)
 | 
			
		||||
            .padding(.vertical, 15)
 | 
			
		||||
            .background(Color(hex: "EC8280"))
 | 
			
		||||
            .cornerRadius(10)
 | 
			
		||||
            
 | 
			
		||||
            HStack(spacing: 4) {
 | 
			
		||||
                Image("ic_can")
 | 
			
		||||
                
 | 
			
		||||
                Text("30")
 | 
			
		||||
                    .font(.custom(Font.preBold.rawValue, size: 24))
 | 
			
		||||
                    .foregroundColor(Color(hex: "263238"))
 | 
			
		||||
                
 | 
			
		||||
                Text("결제하고 바로 대화 시작")
 | 
			
		||||
                    .font(.custom(Font.preBold.rawValue, size: 24))
 | 
			
		||||
                    .foregroundColor(Color(hex: "263238"))
 | 
			
		||||
                    .padding(.leading, 4)
 | 
			
		||||
            }
 | 
			
		||||
            .frame(maxWidth: .infinity)
 | 
			
		||||
            .padding(.vertical, 12)
 | 
			
		||||
            .background(Color(hex: "B5E7FA"))
 | 
			
		||||
            .cornerRadius(30)
 | 
			
		||||
            .overlay {
 | 
			
		||||
                RoundedRectangle(cornerRadius: 30)
 | 
			
		||||
                    .stroke(lineWidth: 1)
 | 
			
		||||
                    .foregroundColor(Color.button)
 | 
			
		||||
            }
 | 
			
		||||
            .onTapGesture {
 | 
			
		||||
                purchase()
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#Preview {
 | 
			
		||||
    ChatQuotaNoticeItemView()
 | 
			
		||||
    ChatQuotaNoticeItemView(remainingTime: "05:59:55") {}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -7,5 +7,5 @@
 | 
			
		||||
 | 
			
		||||
struct ChatQuotaStatusResponse: Decodable {
 | 
			
		||||
    let totalRemaining: Int
 | 
			
		||||
    let nextRechargeAtEpoch: Int64
 | 
			
		||||
    let nextRechargeAtEpoch: Int64?
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,8 @@ enum TalkApi {
 | 
			
		||||
    case enterChatRoom(roomId: Int, characterImageId: Int?)
 | 
			
		||||
    case sendMessage(roomId: Int, request: SendChatMessageRequest)
 | 
			
		||||
    case getChatRoomMessages(roomId: Int, cursor: Int?, limit: Int)
 | 
			
		||||
    
 | 
			
		||||
    case getChatQuotaStatus
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
extension TalkApi: TargetType {
 | 
			
		||||
@@ -35,6 +37,9 @@ extension TalkApi: TargetType {
 | 
			
		||||
        
 | 
			
		||||
        case .getChatRoomMessages(let roomId, _, _):
 | 
			
		||||
            return "/api/chat/room/\(roomId)/messages"
 | 
			
		||||
            
 | 
			
		||||
        case .getChatQuotaStatus:
 | 
			
		||||
            return "/api/chat/quota/me"
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
@@ -54,6 +59,9 @@ extension TalkApi: TargetType {
 | 
			
		||||
            
 | 
			
		||||
        case .getChatRoomMessages:
 | 
			
		||||
            return .get
 | 
			
		||||
            
 | 
			
		||||
        case .getChatQuotaStatus:
 | 
			
		||||
            return .get
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
@@ -91,6 +99,9 @@ extension TalkApi: TargetType {
 | 
			
		||||
                parameters: parameters,
 | 
			
		||||
                encoding: URLEncoding.queryString
 | 
			
		||||
            )
 | 
			
		||||
            
 | 
			
		||||
        case .getChatQuotaStatus:
 | 
			
		||||
            return .requestPlain
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user