feat(chat): 유저 크리에이터 DM을 추가한다
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserCreatorChatTextMessageItemView: View {
|
||||
let message: UserCreatorChatDisplayMessage
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if message.mine {
|
||||
mineMessageRow
|
||||
} else {
|
||||
receivedMessageRow
|
||||
}
|
||||
}
|
||||
|
||||
private var mineMessageRow: some View {
|
||||
HStack(alignment: .bottom, spacing: SodaSpacing.s6) {
|
||||
Spacer(minLength: SodaSpacing.s48)
|
||||
mineMessageMeta
|
||||
messageBubble
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
|
||||
private var receivedMessageRow: some View {
|
||||
HStack(alignment: .top, spacing: SodaSpacing.s6) {
|
||||
senderProfileImage
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
|
||||
senderNickname
|
||||
HStack(alignment: .bottom, spacing: SodaSpacing.s6) {
|
||||
messageBubble
|
||||
timeText
|
||||
}
|
||||
}
|
||||
Spacer(minLength: SodaSpacing.s48)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var senderProfileImage: some View {
|
||||
DownsampledKFImage(
|
||||
url: URL(string: message.senderProfileImageUrl ?? ""),
|
||||
size: CGSize(width: SodaSpacing.s32, height: SodaSpacing.s32)
|
||||
)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private var senderNickname: some View {
|
||||
Text(message.senderNickname ?? "")
|
||||
.appFont(size: 12, weight: .medium)
|
||||
.foregroundColor(Color.gray400)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
private var messageBubble: some View {
|
||||
Text(message.textMessage ?? "")
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, SodaSpacing.s14)
|
||||
.padding(.vertical, SodaSpacing.s12)
|
||||
.background(message.mine ? Color.soda400 : Color.gray900)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
|
||||
private var mineMessageMeta: some View {
|
||||
VStack(alignment: .trailing, spacing: SodaSpacing.s4) {
|
||||
statusText
|
||||
timeText
|
||||
}
|
||||
}
|
||||
|
||||
private var timeText: some View {
|
||||
Text(formatTime(from: message.createdAt))
|
||||
.appFont(size: 11, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusText: some View {
|
||||
switch message.status {
|
||||
case .sent:
|
||||
EmptyView()
|
||||
case .pending:
|
||||
Text(I18n.UserCreatorChat.sendingStatus)
|
||||
.appFont(size: 11, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
case .failed:
|
||||
Text(I18n.UserCreatorChat.failedStatus)
|
||||
.appFont(size: 11, weight: .regular)
|
||||
.foregroundColor(Color.red400)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(from timestamp: Int64) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
|
||||
return date.convertDateFormat(dateFormat: "a hh:mm")
|
||||
}
|
||||
}
|
||||
|
||||
struct UserCreatorChatTextMessageItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: SodaSpacing.s16) {
|
||||
UserCreatorChatTextMessageItemView(
|
||||
message: UserCreatorChatDisplayMessage(
|
||||
pendingText: "안녕하세요",
|
||||
requestId: "preview",
|
||||
createdAt: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(SodaSpacing.s24)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
enum UserCreatorChatDisplayMessageStatus: Equatable {
|
||||
case sent
|
||||
case pending
|
||||
case failed
|
||||
}
|
||||
|
||||
struct UserCreatorChatDisplayMessage: Identifiable, Equatable {
|
||||
let id: String
|
||||
let requestId: String?
|
||||
let messageId: Int?
|
||||
let messageType: String
|
||||
let mine: Bool
|
||||
let createdAt: Int64
|
||||
let textMessage: String?
|
||||
let voiceMessageUrl: String?
|
||||
let senderId: Int?
|
||||
let senderNickname: String?
|
||||
let senderProfileImageUrl: String?
|
||||
var status: UserCreatorChatDisplayMessageStatus
|
||||
|
||||
init(message: UserCreatorChatMessageItem) {
|
||||
self.id = "server-\(message.messageId)"
|
||||
self.requestId = nil
|
||||
self.messageId = message.messageId
|
||||
self.messageType = message.messageType
|
||||
self.mine = message.mine
|
||||
self.createdAt = message.createdAt
|
||||
self.textMessage = message.textMessage
|
||||
self.voiceMessageUrl = message.voiceMessageUrl
|
||||
self.senderId = message.senderId
|
||||
self.senderNickname = message.senderNickname
|
||||
self.senderProfileImageUrl = message.senderProfileImageUrl
|
||||
self.status = .sent
|
||||
}
|
||||
|
||||
init(pendingText text: String, requestId: String, createdAt: Int64) {
|
||||
self.id = "pending-\(requestId)"
|
||||
self.requestId = requestId
|
||||
self.messageId = nil
|
||||
self.messageType = "TEXT"
|
||||
self.mine = true
|
||||
self.createdAt = createdAt
|
||||
self.textMessage = text
|
||||
self.voiceMessageUrl = nil
|
||||
self.senderId = nil
|
||||
self.senderNickname = nil
|
||||
self.senderProfileImageUrl = nil
|
||||
self.status = .pending
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
struct UserCreatorCreateRoomRequest: Encodable {
|
||||
let creatorId: Int
|
||||
}
|
||||
|
||||
struct UserCreatorCreateRoomResponse: Decodable {
|
||||
let roomId: Int
|
||||
}
|
||||
|
||||
struct UserCreatorOpenRoomResponse: Decodable {
|
||||
let roomId: Int
|
||||
let opponentNickname: String
|
||||
let opponentProfileImageUrl: String?
|
||||
let messages: [UserCreatorChatMessageItem]
|
||||
let hasMore: Bool
|
||||
let nextCursor: Int?
|
||||
}
|
||||
|
||||
struct UserCreatorChatMessagesResponse: Decodable {
|
||||
let messages: [UserCreatorChatMessageItem]
|
||||
let hasMore: Bool
|
||||
let nextCursor: Int?
|
||||
}
|
||||
|
||||
struct UserCreatorChatMessageItem: Codable, Identifiable {
|
||||
let messageId: Int
|
||||
let messageType: String
|
||||
let mine: Bool
|
||||
let createdAt: Int64
|
||||
let textMessage: String?
|
||||
let voiceMessageUrl: String?
|
||||
let senderId: Int
|
||||
let senderNickname: String
|
||||
let senderProfileImageUrl: String?
|
||||
|
||||
var id: Int { messageId }
|
||||
}
|
||||
|
||||
struct UserCreatorVoiceMessageResponse: Decodable {
|
||||
let message: UserCreatorChatMessageItem
|
||||
let deliveredRealtime: Bool
|
||||
let pushSent: Bool
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import Moya
|
||||
|
||||
enum UserCreatorChatApi {
|
||||
case createRoom(request: UserCreatorCreateRoomRequest)
|
||||
case openRoom(roomId: Int, limit: Int)
|
||||
case getMessages(roomId: Int, cursor: Int?, limit: Int)
|
||||
case sendVoiceMessage(roomId: Int, parameters: [MultipartFormData])
|
||||
}
|
||||
|
||||
extension UserCreatorChatApi: TargetType {
|
||||
var baseURL: URL {
|
||||
return URL(string: BASE_URL)!
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .createRoom:
|
||||
return "/api/v2/user-creator-chat/rooms/create"
|
||||
case .openRoom(let roomId, _):
|
||||
return "/api/v2/user-creator-chat/rooms/\(roomId)/open"
|
||||
case .getMessages(let roomId, _, _):
|
||||
return "/api/v2/user-creator-chat/rooms/\(roomId)/messages"
|
||||
case .sendVoiceMessage(let roomId, _):
|
||||
return "/api/v2/user-creator-chat/rooms/\(roomId)/messages/voice"
|
||||
}
|
||||
}
|
||||
|
||||
var method: Moya.Method {
|
||||
switch self {
|
||||
case .createRoom, .sendVoiceMessage:
|
||||
return .post
|
||||
case .openRoom, .getMessages:
|
||||
return .get
|
||||
}
|
||||
}
|
||||
|
||||
var task: Moya.Task {
|
||||
switch self {
|
||||
case .createRoom(let request):
|
||||
return .requestJSONEncodable(request)
|
||||
case .openRoom(_, let limit):
|
||||
return .requestParameters(parameters: ["limit": limit], encoding: URLEncoding.queryString)
|
||||
case .getMessages(_, let cursor, let limit):
|
||||
var parameters: [String: Any] = ["limit": limit]
|
||||
if let cursor {
|
||||
parameters["cursor"] = cursor
|
||||
}
|
||||
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
|
||||
case .sendVoiceMessage(_, let parameters):
|
||||
return .uploadMultipart(parameters)
|
||||
}
|
||||
}
|
||||
|
||||
var headers: [String: String]? {
|
||||
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import CombineMoya
|
||||
import Moya
|
||||
|
||||
final class UserCreatorChatRepository {
|
||||
private let api = MoyaProvider<UserCreatorChatApi>()
|
||||
|
||||
func createRoom(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(creatorId: creatorId)))
|
||||
}
|
||||
|
||||
func openRoom(roomId: Int, limit: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.openRoom(roomId: roomId, limit: limit))
|
||||
}
|
||||
|
||||
func getMessages(roomId: Int, cursor: Int?, limit: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(.getMessages(roomId: roomId, cursor: cursor, limit: limit))
|
||||
}
|
||||
|
||||
func sendVoiceMessage(roomId: Int, voiceData: Data) -> AnyPublisher<Response, MoyaError> {
|
||||
let requestData = Data("{\"recipientId\": null}".utf8)
|
||||
let fileName = "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).m4a"
|
||||
let multipartData = [
|
||||
MultipartFormData(provider: .data(voiceData), name: "voiceMessageFile", fileName: fileName, mimeType: "audio/m4a"),
|
||||
MultipartFormData(provider: .data(requestData), name: "request")
|
||||
]
|
||||
return api.requestPublisher(.sendVoiceMessage(roomId: roomId, parameters: multipartData))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct UserCreatorChatRoomView: View {
|
||||
@StateObject private var viewModel = UserCreatorChatRoomViewModel()
|
||||
@State private var messageText = ""
|
||||
|
||||
private let roomId: Int?
|
||||
private let creatorId: Int?
|
||||
|
||||
init(roomId: Int) {
|
||||
self.roomId = roomId
|
||||
self.creatorId = nil
|
||||
}
|
||||
|
||||
init(creatorId: Int) {
|
||||
self.roomId = nil
|
||||
self.creatorId = creatorId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
|
||||
messageListView
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
inputBarView
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
}
|
||||
.onAppear(perform: enterRoom)
|
||||
.onDisappear { viewModel.leaveAndClose() }
|
||||
.sodaToast(
|
||||
isPresented: $viewModel.isShowPopup,
|
||||
message: viewModel.errorMessage,
|
||||
autohideIn: 2
|
||||
)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack(spacing: SodaSpacing.s12) {
|
||||
Image("ic_back")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.onTapGesture { AppState.shared.back() }
|
||||
|
||||
DownsampledKFImage(
|
||||
url: URL(string: viewModel.opponentProfileImageUrl ?? ""),
|
||||
size: CGSize(width: 36, height: 36)
|
||||
)
|
||||
.clipShape(Circle())
|
||||
|
||||
Text(viewModel.opponentNickname)
|
||||
.appFont(size: 14, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s16)
|
||||
.padding(.vertical, SodaSpacing.s8)
|
||||
.frame(width: screenSize().width, height: 60)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private var messageListView: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack(spacing: SodaSpacing.s16) {
|
||||
if viewModel.isLoadingNextPage {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(Color.soda400)
|
||||
.padding(.vertical, SodaSpacing.s12)
|
||||
}
|
||||
|
||||
ForEach(textMessages) { message in
|
||||
UserCreatorChatTextMessageItemView(message: message)
|
||||
.id(message.id)
|
||||
.onAppear {
|
||||
if message.id == textMessages.first?.id {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s24)
|
||||
.padding(.vertical, SodaSpacing.s12)
|
||||
.background(
|
||||
GeometryReader { contentGeometry in
|
||||
Color.clear.preference(
|
||||
key: UserCreatorChatContentHeightPreferenceKey.self,
|
||||
value: contentGeometry.size.height
|
||||
)
|
||||
}
|
||||
)
|
||||
.frame(minHeight: geometry.size.height, alignment: .bottom)
|
||||
}
|
||||
.onPreferenceChange(UserCreatorChatContentHeightPreferenceKey.self) { contentHeight in
|
||||
guard contentHeight <= geometry.size.height else { return }
|
||||
viewModel.loadMore()
|
||||
}
|
||||
.onChange(of: textMessages.last?.id) { _ in
|
||||
scrollToBottom(proxy)
|
||||
}
|
||||
.onChange(of: viewModel.socketState) { state in
|
||||
guard state == .joined else { return }
|
||||
scrollToBottom(proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inputBarView: some View {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
ZStack(alignment: .leading) {
|
||||
if messageText.isEmpty {
|
||||
Text(I18n.UserCreatorChat.messagePlaceholder)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(Color.gray500)
|
||||
}
|
||||
|
||||
TextField("", text: $messageText)
|
||||
.appFont(size: 14, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
.accentColor(Color.soda400)
|
||||
.disabled(isInputEnabled == false)
|
||||
.onSubmit { sendText() }
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s16)
|
||||
.padding(.vertical, SodaSpacing.s12)
|
||||
.background(Color.gray900)
|
||||
.cornerRadius(999)
|
||||
|
||||
Button(action: sendText) {
|
||||
Image("ic_message_send")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.opacity(isSendEnabled ? 1 : 0.4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSendEnabled == false)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s12)
|
||||
.padding(.vertical, SodaSpacing.s12)
|
||||
.frame(width: screenSize().width)
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
private var textMessages: [UserCreatorChatDisplayMessage] {
|
||||
viewModel.messages.filter { $0.messageType == "TEXT" }
|
||||
}
|
||||
|
||||
private var isInputEnabled: Bool {
|
||||
viewModel.socketState == .joined
|
||||
}
|
||||
|
||||
private var isSendEnabled: Bool {
|
||||
isInputEnabled && messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
private func enterRoom() {
|
||||
if let roomId {
|
||||
viewModel.enter(roomId: roomId)
|
||||
} else if let creatorId {
|
||||
viewModel.enter(creatorId: creatorId)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendText() {
|
||||
let trimmedText = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard isInputEnabled, trimmedText.isEmpty == false else { return }
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
viewModel.sendText(trimmedText)
|
||||
messageText = ""
|
||||
}
|
||||
|
||||
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||
guard let lastMessage = textMessages.last else { return }
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
proxy.scrollTo(lastMessage.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct UserCreatorChatContentHeightPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct UserCreatorChatRoomView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UserCreatorChatRoomView(roomId: 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
final class UserCreatorChatRoomViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingNextPage = false
|
||||
@Published var isShowPopup = false
|
||||
@Published var errorMessage = ""
|
||||
@Published private(set) var roomId = 0
|
||||
@Published private(set) var opponentNickname = ""
|
||||
@Published private(set) var opponentProfileImageUrl: String?
|
||||
@Published private(set) var messages = [UserCreatorChatDisplayMessage]()
|
||||
@Published private(set) var hasMore = false
|
||||
@Published private(set) var nextCursor: Int?
|
||||
@Published private(set) var socketState: UserCreatorChatSocketState = .disconnected
|
||||
|
||||
private let repository = UserCreatorChatRepository()
|
||||
private let socketClient = WebSocketChatClient()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
private var ackTimeouts: [String: DispatchWorkItem] = [:]
|
||||
private var reconnectWorkItem: DispatchWorkItem?
|
||||
private var isScreenAlive = true
|
||||
private var shouldReconnect = true
|
||||
private var isAppForeground = UIApplication.shared.applicationState != .background
|
||||
private var hasOpenedRoom = false
|
||||
private var joinRetryCount = 0
|
||||
private let pageLimit = 20
|
||||
private let pendingMatchClockSkewToleranceMilliseconds: Int64 = 5 * 60 * 1000
|
||||
|
||||
init() {
|
||||
bindSocket()
|
||||
observeLifecycle()
|
||||
}
|
||||
|
||||
deinit {
|
||||
leaveAndClose()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
func enter(roomId: Int) {
|
||||
guard roomId > 0 else { return }
|
||||
prepareEnter()
|
||||
openRoom(roomId: roomId)
|
||||
}
|
||||
|
||||
func enter(creatorId: Int) {
|
||||
guard creatorId > 0 else { return }
|
||||
prepareEnter()
|
||||
|
||||
repository.createRoom(creatorId: creatorId)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyRestFailure(message: nil)
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorCreateRoomResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success, data.roomId > 0 {
|
||||
self.openRoom(roomId: data.roomId)
|
||||
} else {
|
||||
self.applyRestFailure(message: decoded.message)
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyRestFailure(message: nil)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func sendText(_ text: String) {
|
||||
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmedText.isEmpty == false else { return }
|
||||
guard socketState == .joined, roomId > 0 else { return }
|
||||
guard let requestId = socketClient.sendText(trimmedText) else { return }
|
||||
|
||||
let pendingMessage = UserCreatorChatDisplayMessage(
|
||||
pendingText: trimmedText,
|
||||
requestId: requestId,
|
||||
createdAt: Int64(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
messages.append(pendingMessage)
|
||||
startAckTimeout(requestId: requestId)
|
||||
}
|
||||
|
||||
func loadMore() {
|
||||
guard roomId > 0,
|
||||
hasMore,
|
||||
isLoading == false,
|
||||
isLoadingNextPage == false,
|
||||
socketState == .joined else { return }
|
||||
isLoadingNextPage = true
|
||||
|
||||
repository.getMessages(roomId: roomId, cursor: nextCursor, limit: pageLimit)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.isLoadingNextPage = false
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
self.isLoadingNextPage = false
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorChatMessagesResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
let previousCursor = self.nextCursor
|
||||
self.hasMore = data.hasMore
|
||||
self.nextCursor = data.nextCursor
|
||||
self.mergeServerMessages(data.messages)
|
||||
|
||||
let shouldContinuePastVoiceOnlyPage = data.hasMore
|
||||
&& data.nextCursor != previousCursor
|
||||
&& data.messages.contains(where: { $0.messageType == "TEXT" }) == false
|
||||
if shouldContinuePastVoiceOnlyPage {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.loadMore()
|
||||
}
|
||||
}
|
||||
} 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 sendVoiceMessage(soundData: Data) {
|
||||
guard roomId > 0 else { return }
|
||||
|
||||
repository.sendVoiceMessage(roomId: roomId, voiceData: soundData)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { result in
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
}
|
||||
} receiveValue: { response in
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorVoiceMessageResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
DEBUG_LOG("deliveredRealtime=\(data.deliveredRealtime), pushSent=\(data.pushSent)")
|
||||
} else if let message = decoded.message {
|
||||
ERROR_LOG(message)
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func leaveAndClose() {
|
||||
isScreenAlive = false
|
||||
shouldReconnect = false
|
||||
cancelReconnect()
|
||||
cancelAckTimeouts()
|
||||
subscription.removeAll()
|
||||
socketClient.close()
|
||||
}
|
||||
|
||||
private func prepareEnter() {
|
||||
cancelReconnect()
|
||||
isScreenAlive = true
|
||||
shouldReconnect = true
|
||||
isAppForeground = UIApplication.shared.applicationState != .background
|
||||
hasOpenedRoom = false
|
||||
joinRetryCount = 0
|
||||
isLoading = true
|
||||
isShowPopup = false
|
||||
errorMessage = ""
|
||||
messages = []
|
||||
hasMore = false
|
||||
nextCursor = nil
|
||||
roomId = 0
|
||||
}
|
||||
|
||||
private func openRoom(roomId: Int) {
|
||||
guard roomId > 0 else { return }
|
||||
|
||||
repository.openRoom(roomId: roomId, limit: pageLimit)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
guard self.isScreenAlive, self.shouldReconnect else { return }
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyRestFailure(message: nil)
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard self.isScreenAlive, self.shouldReconnect else { return }
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorOpenRoomResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success, data.roomId > 0 {
|
||||
self.applyOpenRoom(data)
|
||||
if self.isAppForeground {
|
||||
self.socketClient.connect(roomId: data.roomId)
|
||||
}
|
||||
} else {
|
||||
self.applyRestFailure(message: decoded.message)
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyRestFailure(message: nil)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func applyOpenRoom(_ data: UserCreatorOpenRoomResponse) {
|
||||
roomId = data.roomId
|
||||
hasOpenedRoom = true
|
||||
opponentNickname = data.opponentNickname
|
||||
opponentProfileImageUrl = data.opponentProfileImageUrl
|
||||
hasMore = data.hasMore
|
||||
nextCursor = data.nextCursor
|
||||
messages = data.messages
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
.map { UserCreatorChatDisplayMessage(message: $0) }
|
||||
}
|
||||
|
||||
private func bindSocket() {
|
||||
socketClient.onStateChange = { [weak self] state in
|
||||
DispatchQueue.main.async {
|
||||
self?.socketState = state
|
||||
}
|
||||
}
|
||||
|
||||
socketClient.onEvent = { [weak self] event in
|
||||
DispatchQueue.main.async {
|
||||
self?.handleSocketEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSocketEvent(_ event: UserCreatorChatSocketEvent) {
|
||||
switch event {
|
||||
case .joined:
|
||||
cancelReconnect()
|
||||
joinRetryCount = 0
|
||||
syncLatestMessages(updatePagination: true) { [weak self] isSuccess in
|
||||
guard isSuccess else { return }
|
||||
self?.loadMoreIfLatestPageHasNoText()
|
||||
}
|
||||
case .joinFailed(let message):
|
||||
retryJoinOrFail(messageKey: message)
|
||||
case .sendAck(let requestId, let message):
|
||||
handleSendAck(requestId: requestId, message: message)
|
||||
case .message(let message):
|
||||
mergeServerMessages([message])
|
||||
case .pong:
|
||||
DEBUG_LOG("PONG handled")
|
||||
case .error(let messageKey):
|
||||
if socketState != .joined {
|
||||
retryJoinOrFail(messageKey: messageKey)
|
||||
} else if let messageKey {
|
||||
ERROR_LOG(messageKey)
|
||||
}
|
||||
case .closed:
|
||||
reconnectIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSendAck(requestId: String, message: UserCreatorChatMessageItem?) {
|
||||
ackTimeouts[requestId]?.cancel()
|
||||
ackTimeouts[requestId] = nil
|
||||
|
||||
guard let message else {
|
||||
let pendingMessage = messages.first(where: { $0.requestId == requestId && $0.status == .pending })
|
||||
|
||||
syncLatestMessages { [weak self] _ in
|
||||
guard let self else { return }
|
||||
if let index = self.messages.firstIndex(where: { $0.requestId == requestId && $0.status == .pending }) {
|
||||
if self.hasMatchingOwnServerMessage(for: pendingMessage) {
|
||||
self.messages.remove(at: index)
|
||||
} else {
|
||||
self.messages[index].status = .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let pendingIndex = messages.firstIndex(where: { $0.requestId == requestId }) {
|
||||
messages.remove(at: pendingIndex)
|
||||
}
|
||||
mergeServerMessages([message])
|
||||
}
|
||||
|
||||
private func startAckTimeout(requestId: String) {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
if let index = self.messages.firstIndex(where: { $0.requestId == requestId && $0.status == .pending }) {
|
||||
self.messages[index].status = .failed
|
||||
}
|
||||
self.ackTimeouts[requestId] = nil
|
||||
}
|
||||
|
||||
ackTimeouts[requestId] = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: workItem)
|
||||
}
|
||||
|
||||
private func hasMatchingOwnServerMessage(for pendingMessage: UserCreatorChatDisplayMessage?) -> Bool {
|
||||
guard let pendingMessage else { return false }
|
||||
return messages.contains { message in
|
||||
return message.messageId != nil
|
||||
&& message.mine
|
||||
&& message.textMessage == pendingMessage.textMessage
|
||||
&& isWithinPendingMatchWindow(message.createdAt, pendingMessage.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
private func isWithinPendingMatchWindow(_ lhsCreatedAt: Int64, _ rhsCreatedAt: Int64) -> Bool {
|
||||
abs(lhsCreatedAt - rhsCreatedAt) <= pendingMatchClockSkewToleranceMilliseconds
|
||||
}
|
||||
|
||||
private func mergeServerMessages(_ serverMessages: [UserCreatorChatMessageItem]) {
|
||||
var merged = messages
|
||||
let visibleServerMessages = serverMessages
|
||||
.filter { $0.messageType != "VOICE" }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
|
||||
for serverMessage in visibleServerMessages {
|
||||
let serverDisplayMessage = UserCreatorChatDisplayMessage(message: serverMessage)
|
||||
if let index = merged.firstIndex(where: { $0.messageId == serverMessage.messageId }) {
|
||||
merged[index] = serverDisplayMessage
|
||||
} else {
|
||||
removeMatchingLocalMessage(for: serverMessage, from: &merged)
|
||||
merged.append(serverDisplayMessage)
|
||||
}
|
||||
}
|
||||
|
||||
messages = merged.sorted { lhs, rhs in
|
||||
if lhs.createdAt == rhs.createdAt {
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
return lhs.createdAt < rhs.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMatchingLocalMessage(
|
||||
for serverMessage: UserCreatorChatMessageItem,
|
||||
from messages: inout [UserCreatorChatDisplayMessage]
|
||||
) {
|
||||
guard serverMessage.messageType == "TEXT", serverMessage.mine else { return }
|
||||
let matchingIndexes = messages.indices.filter { index in
|
||||
let message = messages[index]
|
||||
return message.messageId == nil
|
||||
&& message.requestId != nil
|
||||
&& message.mine
|
||||
&& message.messageType == "TEXT"
|
||||
&& message.textMessage == serverMessage.textMessage
|
||||
&& isWithinPendingMatchWindow(serverMessage.createdAt, message.createdAt)
|
||||
}
|
||||
guard let index = matchingIndexes.min(by: { lhs, rhs in
|
||||
let lhsDistance = abs(serverMessage.createdAt - messages[lhs].createdAt)
|
||||
let rhsDistance = abs(serverMessage.createdAt - messages[rhs].createdAt)
|
||||
if lhsDistance == rhsDistance {
|
||||
return messages[lhs].createdAt > messages[rhs].createdAt
|
||||
}
|
||||
return lhsDistance < rhsDistance
|
||||
}) else { return }
|
||||
|
||||
if let requestId = messages[index].requestId {
|
||||
ackTimeouts[requestId]?.cancel()
|
||||
ackTimeouts[requestId] = nil
|
||||
}
|
||||
messages.remove(at: index)
|
||||
}
|
||||
|
||||
private func syncLatestMessages(
|
||||
updatePagination: Bool = false,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
guard roomId > 0 else {
|
||||
completion?(false)
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
|
||||
repository.openRoom(roomId: roomId, limit: pageLimit)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] result in
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self?.applyRestFailure(message: nil)
|
||||
completion?(false)
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else {
|
||||
completion?(false)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorOpenRoomResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
if updatePagination {
|
||||
self.hasMore = data.hasMore
|
||||
self.nextCursor = data.nextCursor
|
||||
}
|
||||
self.mergeServerMessages(data.messages)
|
||||
self.isLoading = false
|
||||
completion?(true)
|
||||
} else {
|
||||
self.applyRestFailure(message: decoded.message)
|
||||
completion?(false)
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyRestFailure(message: nil)
|
||||
completion?(false)
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func retryJoinOrFail(messageKey: String?) {
|
||||
if let messageKey {
|
||||
ERROR_LOG(messageKey)
|
||||
}
|
||||
|
||||
guard joinRetryCount < 3, roomId > 0 else {
|
||||
cancelReconnect()
|
||||
isLoading = false
|
||||
errorMessage = I18n.UserCreatorChat.joinFailureToast
|
||||
isShowPopup = true
|
||||
shouldReconnect = false
|
||||
socketClient.closeWithoutLeave(notify: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
guard let self, self.isScreenAlive, self.shouldReconnect == false else { return }
|
||||
AppState.shared.back()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
joinRetryCount += 1
|
||||
isLoading = true
|
||||
socketClient.closeWithoutLeave(notify: false)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private func reconnectIfNeeded() {
|
||||
guard isScreenAlive, shouldReconnect, isAppForeground, hasOpenedRoom, roomId > 0 else { return }
|
||||
isLoading = true
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private func observeLifecycle() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleDidEnterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleWillEnterForeground),
|
||||
name: UIApplication.willEnterForegroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleCloseNotification),
|
||||
name: .userCreatorChatSessionClose,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleDidEnterBackground() {
|
||||
isAppForeground = false
|
||||
cancelReconnect()
|
||||
failPendingMessages()
|
||||
cancelAckTimeouts()
|
||||
socketClient.close()
|
||||
}
|
||||
|
||||
@objc private func handleWillEnterForeground() {
|
||||
isAppForeground = true
|
||||
guard isScreenAlive, shouldReconnect, hasOpenedRoom, roomId > 0 else { return }
|
||||
cancelReconnect()
|
||||
isLoading = true
|
||||
socketClient.connect(roomId: roomId)
|
||||
}
|
||||
|
||||
@objc private func handleCloseNotification() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.leaveAndClose()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyRestFailure(message: String?) {
|
||||
isLoading = false
|
||||
errorMessage = message ?? I18n.Common.commonError
|
||||
isShowPopup = true
|
||||
}
|
||||
|
||||
private func cancelAckTimeouts() {
|
||||
ackTimeouts.values.forEach { $0.cancel() }
|
||||
ackTimeouts.removeAll()
|
||||
}
|
||||
|
||||
private func failPendingMessages() {
|
||||
for index in messages.indices where messages[index].status == .pending {
|
||||
messages[index].status = .failed
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMoreIfLatestPageHasNoText() {
|
||||
guard messages.contains(where: { $0.messageType == "TEXT" }) == false else { return }
|
||||
loadMore()
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
cancelReconnect()
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self,
|
||||
self.isScreenAlive,
|
||||
self.shouldReconnect,
|
||||
self.isAppForeground,
|
||||
self.hasOpenedRoom,
|
||||
self.roomId > 0 else { return }
|
||||
self.reconnectWorkItem = nil
|
||||
self.socketClient.connect(roomId: self.roomId)
|
||||
}
|
||||
reconnectWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
|
||||
}
|
||||
|
||||
private func cancelReconnect() {
|
||||
reconnectWorkItem?.cancel()
|
||||
reconnectWorkItem = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let userCreatorChatSessionClose = Notification.Name("userCreatorChatSessionClose")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
enum UserCreatorChatSocketState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case socketOpen
|
||||
case joining
|
||||
case joined
|
||||
}
|
||||
|
||||
enum UserCreatorChatSocketEvent {
|
||||
case joined
|
||||
case joinFailed(message: String?)
|
||||
case sendAck(requestId: String, message: UserCreatorChatMessageItem?)
|
||||
case message(UserCreatorChatMessageItem)
|
||||
case pong
|
||||
case error(messageKey: String?)
|
||||
case closed(Error?)
|
||||
}
|
||||
|
||||
enum UserCreatorChatSocketMessageType: String {
|
||||
case joinRoom = "JOIN_ROOM"
|
||||
case sendText = "SEND_TEXT"
|
||||
case leaveRoom = "LEAVE_ROOM"
|
||||
case ping = "PING"
|
||||
case joined = "JOINED"
|
||||
case sendAck = "SEND_ACK"
|
||||
case message = "MESSAGE"
|
||||
case pong = "PONG"
|
||||
case error = "ERROR"
|
||||
}
|
||||
|
||||
struct UserCreatorChatIncomingEnvelope: Decodable {
|
||||
let type: String
|
||||
let requestId: String?
|
||||
let roomId: Int?
|
||||
let payload: UserCreatorChatIncomingPayload?
|
||||
}
|
||||
|
||||
struct UserCreatorChatIncomingPayload: Decodable {
|
||||
let messageKey: String?
|
||||
let message: UserCreatorChatMessageItem?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
messageKey = try container.decodeIfPresent(String.self, forKey: .messageKey)
|
||||
|
||||
if let nestedMessage = try container.decodeIfPresent(UserCreatorChatMessageItem.self, forKey: .message) {
|
||||
message = nestedMessage
|
||||
} else {
|
||||
message = try? UserCreatorChatMessageItem(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case messageKey
|
||||
case message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import Foundation
|
||||
|
||||
final class WebSocketChatClient: NSObject {
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private var heartbeatTimer: Timer?
|
||||
private var joinTimeoutWorkItem: DispatchWorkItem?
|
||||
private var roomId: Int = 0
|
||||
private var isClosing = false
|
||||
private let joinTimeout: TimeInterval = 15
|
||||
|
||||
private(set) var state: UserCreatorChatSocketState = .disconnected {
|
||||
didSet {
|
||||
onStateChange?(state)
|
||||
}
|
||||
}
|
||||
|
||||
var onStateChange: ((UserCreatorChatSocketState) -> Void)?
|
||||
var onEvent: ((UserCreatorChatSocketEvent) -> Void)?
|
||||
|
||||
func connect(roomId: Int) {
|
||||
performOnMain {
|
||||
connectOnMain(roomId: roomId)
|
||||
}
|
||||
}
|
||||
|
||||
func sendText(_ text: String) -> String? {
|
||||
return performOnMain {
|
||||
sendTextOnMain(text)
|
||||
}
|
||||
}
|
||||
|
||||
func ping() {
|
||||
performOnMain {
|
||||
pingOnMain()
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
performOnMain {
|
||||
closeOnMain()
|
||||
}
|
||||
}
|
||||
|
||||
func closeWithoutLeave(notify: Bool = true) {
|
||||
performOnMain {
|
||||
closeWithoutLeaveOnMain(notify: notify)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectOnMain(roomId: Int) {
|
||||
guard roomId > 0 else { return }
|
||||
closeWithoutLeaveOnMain(notify: false)
|
||||
|
||||
self.roomId = roomId
|
||||
isClosing = false
|
||||
state = .connecting
|
||||
|
||||
guard let url = makeWebSocketURL() else {
|
||||
state = .disconnected
|
||||
onEvent?(.closed(nil))
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let delegateQueue = OperationQueue()
|
||||
delegateQueue.maxConcurrentOperationCount = 1
|
||||
let session = URLSession(configuration: .default, delegate: self, delegateQueue: delegateQueue)
|
||||
let task = session.webSocketTask(with: request)
|
||||
self.session = session
|
||||
self.task = task
|
||||
task.resume()
|
||||
receiveLoop(for: task)
|
||||
}
|
||||
|
||||
private func sendTextOnMain(_ text: String) -> String? {
|
||||
guard state == .joined, roomId > 0 else { return nil }
|
||||
let requestId = UUID().uuidString
|
||||
send(type: .sendText, requestId: requestId, payload: ["textMessage": text])
|
||||
return requestId
|
||||
}
|
||||
|
||||
private func pingOnMain() {
|
||||
guard state == .joined, roomId > 0 else { return }
|
||||
send(type: .ping, requestId: UUID().uuidString, payload: [:])
|
||||
}
|
||||
|
||||
private func closeOnMain() {
|
||||
isClosing = true
|
||||
stopHeartbeat()
|
||||
cancelJoinTimeout()
|
||||
|
||||
guard let task else {
|
||||
state = .disconnected
|
||||
return
|
||||
}
|
||||
|
||||
if state == .joined, roomId > 0 {
|
||||
send(type: .leaveRoom, requestId: UUID().uuidString, payload: [:]) { [weak self] _ in
|
||||
self?.cancelTask(task)
|
||||
}
|
||||
} else {
|
||||
cancelTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
private func closeWithoutLeaveOnMain(notify: Bool) {
|
||||
isClosing = true
|
||||
stopHeartbeat()
|
||||
cancelJoinTimeout()
|
||||
if let task {
|
||||
cancelTask(task)
|
||||
} else {
|
||||
state = .disconnected
|
||||
if notify {
|
||||
onEvent?(.closed(nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func joinRoomIfPossible() {
|
||||
guard state == .socketOpen, roomId > 0 else { return }
|
||||
state = .joining
|
||||
startJoinTimeout()
|
||||
send(type: .joinRoom, requestId: UUID().uuidString, payload: [:]) { [weak self] error in
|
||||
guard let error else { return }
|
||||
self?.handleJoinFailure(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(
|
||||
type: UserCreatorChatSocketMessageType,
|
||||
requestId: String,
|
||||
payload: [String: Any],
|
||||
completion: ((Error?) -> Void)? = nil
|
||||
) {
|
||||
guard roomId > 0 else {
|
||||
completion?(WebSocketChatClientSendError.invalidState)
|
||||
return
|
||||
}
|
||||
guard type == .joinRoom || state == .joined else {
|
||||
completion?(WebSocketChatClientSendError.invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
let dictionary: [String: Any] = [
|
||||
"type": type.rawValue,
|
||||
"requestId": requestId,
|
||||
"roomId": roomId,
|
||||
"payload": payload
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: dictionary),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
completion?(WebSocketChatClientSendError.encodingFailed)
|
||||
return
|
||||
}
|
||||
|
||||
DEBUG_LOG(json)
|
||||
guard let task else {
|
||||
completion?(WebSocketChatClientSendError.taskUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
task.send(.string(json)) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
}
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLoop(for task: URLSessionWebSocketTask) {
|
||||
task.receive { [weak self] result in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, self.isCurrentTask(task) else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self.handle(message)
|
||||
self.receiveLoop(for: task)
|
||||
case .failure(let error):
|
||||
self.finishCurrentTask(task, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ message: URLSessionWebSocketTask.Message) {
|
||||
let rawText: String?
|
||||
switch message {
|
||||
case .string(let text):
|
||||
rawText = text
|
||||
case .data(let data):
|
||||
rawText = String(data: data, encoding: .utf8)
|
||||
@unknown default:
|
||||
rawText = nil
|
||||
}
|
||||
|
||||
guard let rawText else { return }
|
||||
DEBUG_LOG(rawText)
|
||||
|
||||
guard let data = rawText.data(using: .utf8),
|
||||
let envelope = try? JSONDecoder().decode(UserCreatorChatIncomingEnvelope.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch envelope.type {
|
||||
case UserCreatorChatSocketMessageType.joined.rawValue:
|
||||
guard state == .joining else { return }
|
||||
cancelJoinTimeout()
|
||||
state = .joined
|
||||
startHeartbeat()
|
||||
onEvent?(.joined)
|
||||
case UserCreatorChatSocketMessageType.sendAck.rawValue:
|
||||
if let requestId = envelope.requestId {
|
||||
onEvent?(.sendAck(requestId: requestId, message: envelope.payload?.message))
|
||||
}
|
||||
case UserCreatorChatSocketMessageType.message.rawValue:
|
||||
if let message = envelope.payload?.message {
|
||||
onEvent?(.message(message))
|
||||
}
|
||||
case UserCreatorChatSocketMessageType.pong.rawValue:
|
||||
DEBUG_LOG("PONG")
|
||||
onEvent?(.pong)
|
||||
case UserCreatorChatSocketMessageType.error.rawValue:
|
||||
if state != .joined {
|
||||
cancelJoinTimeout()
|
||||
}
|
||||
let messageKey = envelope.payload?.messageKey
|
||||
if let messageKey {
|
||||
ERROR_LOG(messageKey)
|
||||
}
|
||||
onEvent?(.error(messageKey: messageKey))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startHeartbeat() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.state == .joined else { return }
|
||||
self.stopHeartbeat()
|
||||
self.heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||||
self?.ping()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startJoinTimeout() {
|
||||
cancelJoinTimeout()
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.state == .joining else { return }
|
||||
self.joinTimeoutWorkItem = nil
|
||||
self.handleJoinFailure(message: "JOINED timeout")
|
||||
}
|
||||
joinTimeoutWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + joinTimeout, execute: workItem)
|
||||
}
|
||||
|
||||
private func cancelJoinTimeout() {
|
||||
joinTimeoutWorkItem?.cancel()
|
||||
joinTimeoutWorkItem = nil
|
||||
}
|
||||
|
||||
private func handleJoinFailure(message: String?) {
|
||||
guard state == .joining else { return }
|
||||
if let message {
|
||||
ERROR_LOG(message)
|
||||
}
|
||||
closeWithoutLeaveOnMain(notify: false)
|
||||
onEvent?(.joinFailed(message: message))
|
||||
}
|
||||
|
||||
private func stopHeartbeat() {
|
||||
let invalidate = { [weak self] in
|
||||
self?.heartbeatTimer?.invalidate()
|
||||
self?.heartbeatTimer = nil
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
invalidate()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: invalidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelTask(_ task: URLSessionWebSocketTask) {
|
||||
cancelJoinTimeout()
|
||||
task.cancel(with: .normalClosure, reason: nil)
|
||||
if isCurrentTask(task) {
|
||||
self.task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
state = .disconnected
|
||||
}
|
||||
}
|
||||
|
||||
private func finishCurrentTask(_ task: URLSessionWebSocketTask, error: Error?) {
|
||||
guard isCurrentTask(task) else { return }
|
||||
let shouldNotify = isClosing == false
|
||||
|
||||
stopHeartbeat()
|
||||
cancelJoinTimeout()
|
||||
self.task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
state = .disconnected
|
||||
|
||||
if shouldNotify {
|
||||
if let error {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
}
|
||||
onEvent?(.closed(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func isCurrentTask(_ task: URLSessionWebSocketTask) -> Bool {
|
||||
self.task === task
|
||||
}
|
||||
|
||||
private func makeWebSocketURL() -> URL? {
|
||||
guard var components = URLComponents(string: BASE_URL), let scheme = components.scheme else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.scheme = scheme == "http" ? "ws" : "wss"
|
||||
components.path = "/ws/v2/user-creator-chat"
|
||||
components.query = nil
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func performOnMain<T>(_ work: () -> T) -> T {
|
||||
if Thread.isMainThread {
|
||||
return work()
|
||||
}
|
||||
return DispatchQueue.main.sync(execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
private enum WebSocketChatClientSendError: LocalizedError {
|
||||
case invalidState
|
||||
case encodingFailed
|
||||
case taskUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidState:
|
||||
return "WebSocket is not ready to send"
|
||||
case .encodingFailed:
|
||||
return "Failed to encode WebSocket message"
|
||||
case .taskUnavailable:
|
||||
return "WebSocket task is unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WebSocketChatClient: URLSessionWebSocketDelegate {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didOpenWithProtocol protocol: String?
|
||||
) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.isCurrentTask(webSocketTask) else { return }
|
||||
self.state = .socketOpen
|
||||
self.joinRoomIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||
reason: Data?
|
||||
) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.finishCurrentTask(webSocketTask, error: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user