feat(chat): 유저 크리에이터 DM을 추가한다
This commit is contained in:
@@ -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