feat(chat): 유저 크리에이터 DM을 추가한다

This commit is contained in:
Yu Sung
2026-07-12 04:48:17 +09:00
parent ce191539f2
commit 2dfff32ac1
23 changed files with 2410 additions and 6 deletions

View File

@@ -81,8 +81,13 @@ struct MainChatView: View {
}
private func handleChatRoomTap(roomId: Int, chatType: String) {
guard chatType == "AI" else { return }
AppState.shared.setAppStep(step: .chatRoom(id: roomId))
guard roomId > 0 else { return }
if chatType == "AI" {
AppState.shared.setAppStep(step: .chatRoom(id: roomId))
} else if chatType == "DM" {
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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))"]
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,5 @@
import Foundation
extension Notification.Name {
static let userCreatorChatSessionClose = Notification.Name("userCreatorChatSessionClose")
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}