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

@@ -1811,6 +1811,16 @@
C0A741000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
C0A741000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
C0A741000000000000000008 /* UserCreatorChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000008 /* UserCreatorChatModels.swift */; };
C0A741000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */; };
C0A74100000000000000000A /* UserCreatorChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000A /* UserCreatorChatApi.swift */; };
C0A74100000000000000000B /* UserCreatorChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000B /* UserCreatorChatRepository.swift */; };
C0A74100000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */; };
C0A74100000000000000000D /* WebSocketChatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000D /* WebSocketChatClient.swift */; };
C0A74100000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */; };
C0A74100000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */; };
C0A741000000000000000010 /* UserCreatorChatRoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000010 /* UserCreatorChatRoomView.swift */; };
C0A741000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */; };
C0A742000000000000000001 /* MainChatRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000001 /* MainChatRoomsResponse.swift */; };
C0A742000000000000000002 /* MainChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000002 /* MainChatApi.swift */; };
C0A742000000000000000003 /* MainChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000003 /* MainChatRepository.swift */; };
@@ -1825,6 +1835,16 @@
C0A743000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
C0A743000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
C0A743000000000000000008 /* UserCreatorChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000008 /* UserCreatorChatModels.swift */; };
C0A743000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */; };
C0A74300000000000000000A /* UserCreatorChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000A /* UserCreatorChatApi.swift */; };
C0A74300000000000000000B /* UserCreatorChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000B /* UserCreatorChatRepository.swift */; };
C0A74300000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */; };
C0A74300000000000000000D /* WebSocketChatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000D /* WebSocketChatClient.swift */; };
C0A74300000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */; };
C0A74300000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */; };
C0A743000000000000000010 /* UserCreatorChatRoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000010 /* UserCreatorChatRoomView.swift */; };
C0A743000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */; };
C0D6E325AEFEEF476E7044AB /* CommunityPostCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82912F2F111593BE42E33C /* CommunityPostCard.swift */; };
C3CFF5936F1BAC5BC12E9DDB /* UpdateContentPreferenceRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDFAA64C2CA4FCBBF00A024 /* UpdateContentPreferenceRequest.swift */; };
C45F071870A4DB48ABAE8BC5 /* YandexAdSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F064EC9D5711B91979F45C05 /* YandexAdSupport.swift */; };
@@ -2975,6 +2995,16 @@
C0A740000000000000000005 /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = "<group>"; };
C0A740000000000000000006 /* MainChatRoomListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatRoomListItem.swift; sourceTree = "<group>"; };
C0A740000000000000000007 /* MainChatFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatFilter.swift; sourceTree = "<group>"; };
C0A740000000000000000008 /* UserCreatorChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatModels.swift; sourceTree = "<group>"; };
C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatDisplayMessage.swift; sourceTree = "<group>"; };
C0A74000000000000000000A /* UserCreatorChatApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatApi.swift; sourceTree = "<group>"; };
C0A74000000000000000000B /* UserCreatorChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRepository.swift; sourceTree = "<group>"; };
C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatWebSocketModels.swift; sourceTree = "<group>"; };
C0A74000000000000000000D /* WebSocketChatClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketChatClient.swift; sourceTree = "<group>"; };
C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatTextMessageItemView.swift; sourceTree = "<group>"; };
C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRoomViewModel.swift; sourceTree = "<group>"; };
C0A740000000000000000010 /* UserCreatorChatRoomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRoomView.swift; sourceTree = "<group>"; };
C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatSessionNotification.swift; sourceTree = "<group>"; };
C1165E3F4CA1AB019FC85067 /* LanguageRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageRepository.swift; sourceTree = "<group>"; };
C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingIconButton.swift; sourceTree = "<group>"; };
C6B18C00DE256DCC53030630 /* Spacing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Spacing.swift; sourceTree = "<group>"; };
@@ -5842,6 +5872,7 @@
C0A74000000000000000000101 /* Models */,
C0A74000000000000000000102 /* Repository */,
C0A74000000000000000000103 /* Components */,
C0A74000000000000000000104 /* UserCreatorChat */,
C0A740000000000000000004 /* MainChatViewModel.swift */,
C0A740000000000000000005 /* MainChatView.swift */,
C0A740000000000000000007 /* MainChatFilter.swift */,
@@ -5874,6 +5905,55 @@
path = Components;
sourceTree = "<group>";
};
C0A74000000000000000000104 /* UserCreatorChat */ = {
isa = PBXGroup;
children = (
C0A74000000000000000000105 /* Models */,
C0A74000000000000000000106 /* Repository */,
C0A74000000000000000000107 /* WebSocket */,
C0A74000000000000000000108 /* Components */,
C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */,
C0A740000000000000000010 /* UserCreatorChatRoomView.swift */,
C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */,
);
path = UserCreatorChat;
sourceTree = "<group>";
};
C0A74000000000000000000105 /* Models */ = {
isa = PBXGroup;
children = (
C0A740000000000000000008 /* UserCreatorChatModels.swift */,
C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */,
);
path = Models;
sourceTree = "<group>";
};
C0A74000000000000000000106 /* Repository */ = {
isa = PBXGroup;
children = (
C0A74000000000000000000A /* UserCreatorChatApi.swift */,
C0A74000000000000000000B /* UserCreatorChatRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
C0A74000000000000000000107 /* WebSocket */ = {
isa = PBXGroup;
children = (
C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */,
C0A74000000000000000000D /* WebSocketChatClient.swift */,
);
path = WebSocket;
sourceTree = "<group>";
};
C0A74000000000000000000108 /* Components */ = {
isa = PBXGroup;
children = (
C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */,
);
path = Components;
sourceTree = "<group>";
};
C46DB3E329B349CBB3F472DF /* Views */ = {
isa = PBXGroup;
children = (
@@ -6774,6 +6854,16 @@
C0A741000000000000000005 /* MainChatView.swift in Sources */,
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */,
C0A741000000000000000007 /* MainChatFilter.swift in Sources */,
C0A741000000000000000008 /* UserCreatorChatModels.swift in Sources */,
C0A741000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */,
C0A74100000000000000000A /* UserCreatorChatApi.swift in Sources */,
C0A74100000000000000000B /* UserCreatorChatRepository.swift in Sources */,
C0A74100000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */,
C0A74100000000000000000D /* WebSocketChatClient.swift in Sources */,
C0A74100000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */,
C0A74100000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */,
C0A741000000000000000010 /* UserCreatorChatRoomView.swift in Sources */,
C0A741000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */,
C0A701000000000000000001 /* MainContentView.swift in Sources */,
C0A701000000000000000002 /* MainContentTab.swift in Sources */,
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */,
@@ -8618,6 +8708,16 @@
C0A743000000000000000005 /* MainChatView.swift in Sources */,
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */,
C0A743000000000000000007 /* MainChatFilter.swift in Sources */,
C0A743000000000000000008 /* UserCreatorChatModels.swift in Sources */,
C0A743000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */,
C0A74300000000000000000A /* UserCreatorChatApi.swift in Sources */,
C0A74300000000000000000B /* UserCreatorChatRepository.swift in Sources */,
C0A74300000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */,
C0A74300000000000000000D /* WebSocketChatClient.swift in Sources */,
C0A74300000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */,
C0A74300000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */,
C0A743000000000000000010 /* UserCreatorChatRoomView.swift in Sources */,
C0A743000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */,
C0A703000000000000000001 /* MainContentView.swift in Sources */,
C0A703000000000000000002 /* MainContentTab.swift in Sources */,
C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */,

View File

@@ -2,6 +2,7 @@ import Foundation
enum AppDeepLinkAction {
case live(roomId: Int)
case userCreatorChat(roomId: Int)
case content(contentId: Int)
case series(seriesId: Int)
case community(creatorId: Int, postId: Int?)
@@ -57,6 +58,10 @@ enum AppDeepLinkHandler {
AppState.shared.pushRoomId = 0
AppState.shared.pushRoomId = roomId
case .userCreatorChat(let roomId):
guard roomId > 0 else { return }
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
case .content(let contentId):
guard contentId > 0 else { return }
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
@@ -166,6 +171,12 @@ enum AppDeepLinkHandler {
}
return .live(roomId: roomId)
case "chat":
guard let identifier = identifier, let roomId = Int(identifier), roomId > 0 else {
return nil
}
return .userCreatorChat(roomId: roomId)
case "content":
guard let identifier = identifier, let contentId = Int(identifier), contentId > 0 else {
return nil

View File

@@ -180,7 +180,11 @@ enum AppStep {
case characterDetail(characterId: Int)
case chatRoom(id: Int)
case userCreatorChatRoom(roomId: Int)
case userCreatorChatCreator(creatorId: Int)
case newCharacterAll
case originalWorkDetail(originalId: Int)

View File

@@ -321,6 +321,12 @@ struct AppStepLayerView: View {
case .chatRoom(let id):
ChatRoomView(roomId: id)
case .userCreatorChatRoom(let roomId):
UserCreatorChatRoomView(roomId: roomId)
case .userCreatorChatCreator(let creatorId):
UserCreatorChatRoomView(creatorId: creatorId)
case .newCharacterAll:
NewCharacterListView()

View File

@@ -11,6 +11,28 @@ import Foundation
// String Catalog i18n.
// LanguageHeaderProvider.current("ko"|"en"|"ja").
enum I18n {
enum UserCreatorChat {
static var messagePlaceholder: String {
pick(ko: "메시지를 입력하세요", en: "Enter a message", ja: "メッセージを入力してください")
}
static var sendingStatus: String {
pick(ko: "전송 중", en: "Sending", ja: "送信中")
}
static var failedStatus: String {
pick(ko: "실패", en: "Failed", ja: "失敗")
}
static var joinFailureToast: String {
pick(
ko: "대화방에 접속하지 못했습니다.",
en: "Failed to join the chat room.",
ja: "チャットルームに接続できませんでした。"
)
}
}
// ( )
enum ChatRoom {
//
@@ -3412,6 +3434,7 @@ If you block this user, the following features will be restricted.
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }

View File

@@ -214,6 +214,7 @@ struct SettingsView: View {
ContentPlayManager.shared.stopAudio()
ContentPlayerPlayManager.shared.resetPlayer()
viewModel.logout {
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
self.isShowLogoutDialog = false
UserDefaults.reset()
@@ -239,6 +240,7 @@ struct SettingsView: View {
ContentPlayManager.shared.stopAudio()
ContentPlayerPlayManager.shared.resetPlayer()
viewModel.logoutAllDevice {
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
self.isShowLogoutAllDeviceDialog = false
UserDefaults.reset()
AppState.shared.isRestartApp = true

View File

@@ -44,6 +44,7 @@ final class SignOutViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
UserDefaults.reset()
AppState.shared.isRestartApp = true
AppState.shared.setAppStep(step: .splash)

View File

@@ -4,6 +4,7 @@ import Kingfisher
struct CreatorChannelHeaderSection: View {
let creator: CreatorChannelCreatorResponse
let onTapTalk: () -> Void
let onTapDm: () -> Void
var body: some View {
ZStack(alignment: .bottom) {
@@ -52,7 +53,9 @@ struct CreatorChannelHeaderSection: View {
CreatorChannelTalkActionSection(
isAiChatAvailable: creator.isAiChatAvailable,
onTapTalk: onTapTalk
isDmAvailable: creator.isDmAvailable,
onTapTalk: onTapTalk,
onTapDm: onTapDm
)
}
.frame(maxWidth: .infinity)

View File

@@ -234,7 +234,11 @@ struct CreatorChannelView: View {
@ViewBuilder
private var headerSection: some View {
if let creator = viewModel.response?.creator {
CreatorChannelHeaderSection(creator: creator, onTapTalk: handleTalkTap)
CreatorChannelHeaderSection(
creator: creator,
onTapTalk: handleTalkTap,
onTapDm: handleDmTap
)
}
}
@@ -544,6 +548,18 @@ struct CreatorChannelView: View {
startChat(characterId: characterId)
}
private func handleDmTap() {
guard let creatorId = viewModel.response?.creator.creatorId, creatorId > 0 else { return }
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
AppState.shared.setAppStep(step: .login)
return
}
AppState.shared.setAppStep(step: .userCreatorChatCreator(creatorId: creatorId))
}
private func startChat(characterId: Int) {
viewModel.createChatRoom(characterId: characterId) { chatRoomId in
AppState.shared.setAppStep(step: .chatRoom(id: chatRoomId))

View File

@@ -2,10 +2,12 @@ import SwiftUI
struct CreatorChannelTalkActionSection: View {
let isAiChatAvailable: Bool
let isDmAvailable: Bool
let onTapTalk: () -> Void
let onTapDm: () -> Void
var body: some View {
HStack {
HStack(spacing: SodaSpacing.s8) {
Spacer()
if isAiChatAvailable {
@@ -28,6 +30,26 @@ struct CreatorChannelTalkActionSection: View {
.buttonStyle(.plain)
}
if isDmAvailable {
Button(action: onTapDm) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_talk")
.resizable()
.frame(width: 20, height: 20)
Text(I18n.CreatorChannelHome.dm)
.appFont(size: 16, weight: .medium)
.foregroundColor(.white)
}
.padding(SodaSpacing.s12)
.overlay(
Capsule()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
Spacer()
}
.frame(height: 44)

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

View File

@@ -0,0 +1,469 @@
# 계획/TASK: 유저-크리에이터 1:1 채팅
관련 PRD: `docs/20260711_유저_크리에이터_1대1_채팅/prd.md`
## 작업 원칙
- REST는 Moya `TargetType`로만 구현한다.
- 실시간 텍스트 채팅은 `URLSessionWebSocketTask` raw WebSocket으로 구현한다.
- STOMP/SockJS 클라이언트 및 STOMP frame은 사용하지 않는다.
- `roomId`가 nil 또는 0이면 WebSocket outgoing 명령을 보내지 않는다.
- UI 연결 완료 기준은 WebSocket open이 아니라 서버 `JOINED` 수신이다.
## 재사용 vs 신규
- 재사용:
- `ApiResponse<T>`: `SodaLive/Sources/Common/ApiResponse.swift`
- Moya `TargetType` + Repository + Combine 패턴: `SodaLive/Sources/V2/Main/Chat/Repository/MainChatApi.swift`, `MainChatRepository.swift`
- AI 채팅방 UI 구조: `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
- Loading/Toast: `BaseView(isLoading:)`, `.sodaToast`
- 로그: `DEBUG_LOG`, `ERROR_LOG`
- 앱 라우팅: `AppStep`, `AppState`, `ContentView`
- 기존 음성 multipart 패턴: `SodaLive/Sources/Message/MessageApi.swift`, `SodaLive/Sources/Message/Voice/VoiceMessageViewModel.swift`
- 신규:
- 유저-크리에이터 채팅 REST API/Repository/DTO
- `WebSocketChatClient` raw WebSocket 객체
- DM 채팅방 View/ViewModel/전용 메시지 컴포넌트
- DM deep link action 및 route
- 세션 종료 알림 또는 훅
---
### Phase 1: REST DTO/API/Repository
- [x] **Task 1.1: REST/Message DTO 정의**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift`
- 작업 내용:
- `UserCreatorCreateRoomRequest(creatorId: Int)` 정의.
- `UserCreatorCreateRoomResponse(roomId: Int)` 정의.
- `UserCreatorOpenRoomResponse(roomId, opponentNickname, opponentProfileImageUrl, messages, hasMore, nextCursor)` 정의.
- `UserCreatorChatMessagesResponse(messages, hasMore, nextCursor)` 정의.
- `UserCreatorChatMessageItem: Codable` 정의.
- `UserCreatorVoiceMessageResponse(message, deliveredRealtime, pushSent)` 정의.
- 검증 기준:
- 실행 명령: `rg "UserCreatorChatMessageItem|UserCreatorOpenRoomResponse|UserCreatorVoiceMessageResponse" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models`
- 기대 결과: 서버 계약 필드가 Swift DTO에 빠짐없이 선언되어 있다.
- [x] **Task 1.2: Moya TargetType 추가**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatApi.swift`
- 작업 내용:
- case 추가:
- `createRoom(request:)`
- `openRoom(roomId:limit:)`
- `getMessages(roomId:cursor:limit:)`
- `sendVoiceMessage(roomId:parameters:)`
- path:
- `/api/v2/user-creator-chat/rooms/create`
- `/api/v2/user-creator-chat/rooms/{roomId}/open`
- `/api/v2/user-creator-chat/rooms/{roomId}/messages`
- `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
- `Authorization: Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))` 헤더 추가.
- 음성 메시지는 `.uploadMultipart` 사용.
- 제거된 event/text REST API는 추가하지 않는다.
- 검증 기준:
- 실행 명령: `rg "/api/v2/user-creator-chat|uploadMultipart|Authorization" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatApi.swift`
- 기대 결과: 4개 REST API만 존재하고 제거 API path는 없다.
- [x] **Task 1.3: Repository 추가**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift`
- 작업 내용:
- `createRoom(creatorId:)`, `openRoom(roomId:limit:)`, `getMessages(roomId:cursor:limit:)`, `sendVoiceMessage(roomId:voiceData:)` 추가.
- 음성 multipart의 `request` part는 JSON 문자열 `{"recipientId": null}` 형태로 전송한다.
- 검증 기준:
- 실행 명령: `rg "createRoom|openRoom|getMessages|sendVoiceMessage|voiceMessageFile|recipientId" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository`
- 기대 결과: Repository 메서드와 multipart part 이름이 서버 계약과 일치한다.
### Phase 2: Raw WebSocket Client
- [x] **Task 2.1: WebSocket envelope/상태 모델 정의**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/UserCreatorChatWebSocketModels.swift`
- 작업 내용:
- `UserCreatorChatSocketState`: `disconnected`, `connecting`, `socketOpen`, `joining`, `joined`.
- outgoing/incoming envelope 정의: `type`, `requestId`, `roomId`, `payload`.
- `ERROR` payload는 `messageKey`를 파싱한다.
- 검증 기준:
- 실행 명령: `rg "socketOpen|joining|joined|messageKey|SEND_TEXT|JOIN_ROOM" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket`
- 기대 결과: 상태와 protocol type/payload 모델이 확인된다.
- [x] **Task 2.2: WebSocketChatClient 구현**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
- 작업 내용:
- `URLSessionWebSocketTask` 사용.
- `BASE_URL`의 scheme/host를 `ws`/`wss`로 변환해 `/ws/v2/user-creator-chat` URL 생성.
- `URLRequest``Authorization` 헤더 추가.
- `connect(roomId:)`는 socket open 후 실제 `roomId > 0`일 때만 `JOIN_ROOM` 전송.
- outgoing JSON과 incoming raw text를 `DEBUG_LOG`로 출력.
- receive loop에서 `JOINED`, `SEND_ACK`, `MESSAGE`, `PONG`, `ERROR` event를 delegate/closure로 전달.
- `sendText`, `ping`, `leaveRoom``joined` 상태와 `roomId > 0`을 모두 만족할 때만 전송.
- `close()`는 가능한 경우 `LEAVE_ROOM` 전송 후 cancel/close 처리.
- 검증 기준:
- 실행 명령: `rg "URLSessionWebSocketTask|URLRequest|Authorization|/ws/v2/user-creator-chat|DEBUG_LOG|JOIN_ROOM|LEAVE_ROOM" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
- 기대 결과: raw WebSocket 구현과 guard/logging이 확인된다.
- 실행 명령: `rg "Stomp|SockJS|CONNECT|SUBSCRIBE" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
- 기대 결과: 검색 결과가 없어야 한다.
- [x] **Task 2.3: heartbeat 및 ACK timeout 기반**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
- 작업 내용:
- `joined` 후 30초 주기 `PING` timer 시작.
- `PONG` 수신 로그/상태 갱신.
- socket close/error를 상위 ViewModel에 전달해 재연결 판단은 ViewModel이 수행하게 한다.
- 검증 기준:
- 실행 명령: `rg "30|PING|PONG|Timer|joined" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
- 기대 결과: heartbeat가 `joined` 이후에만 동작한다.
### Phase 3: ViewModel 상태/메시지 처리
- [x] **Task 3.1: DM 채팅방 ViewModel 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDisplayMessage.swift`
- 작업 내용:
- 상태: `isLoading`, `isShowPopup`, `errorMessage`, `roomId`, `opponentNickname`, `opponentProfileImageUrl`, `messages`, `hasMore`, `nextCursor`, `socketState`.
- `enter(roomId:)`: `openRoom` 성공 후 실제 roomId로 WebSocket connect/join.
- `enter(creatorId:)`: `createRoom` 성공 후 `openRoom` + connect/join.
- `isLoading`은 REST 호출 및 최초 `JOINED` 대기 동안 true 유지.
- 검증 기준:
- 실행 명령: `rg "enter\\(roomId|enter\\(creatorId|openRoom|socketState|opponentNickname|isLoading" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 기대 결과: roomId/creatorId 진입 흐름과 Loading 상태가 확인된다.
- [x] **Task 3.2: JOIN/ERROR 재시도 정책 구현**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- `JOINED``ERROR` 수신 시 최대 3회 재시도.
- 3회 실패 시 Loading 해제, 토스트 메시지 `대화방에 접속하지 못했습니다.`, socket close, `AppState.shared.back()` 호출.
- `payload.messageKey`를 로그/분기 기준으로 사용한다.
- 검증 기준:
- 실행 명령: `rg "joinRetry|messageKey|대화방에 접속하지 못했습니다|AppState.shared.back" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 기대 결과: JOIN 전 ERROR 3회 재시도와 실패 후 뒤로가기 처리가 확인된다.
- [x] **Task 3.3: 메시지 pending/ACK/중복 제거 구현**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDisplayMessage.swift`
- 작업 내용:
- `sendText()``joined` 상태에서만 WebSocket `SEND_TEXT` 전송.
- requestId로 pending 메시지를 append.
- 15초 timeout 후 failed 상태 표시.
- `SEND_ACK`의 requestId로 pending 메시지를 서버 메시지로 교체.
- `MESSAGE`와 REST 응답은 `messageId` 기준 merge/dedupe.
- 검증 기준:
- 실행 명령: `rg "sendText|pending|SEND_ACK|requestId|15|messageId" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
- 기대 결과: pending/ACK/timeout/dedupe 흐름이 확인된다.
- [x] **Task 3.4: 과거 메시지/재동기화 구현**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- 스크롤 상단 도달 시 `getMessages(roomId:cursor:limit:)` 호출.
- `hasMore`/`nextCursor` 관리.
- 재연결 후 필요 시 `openRoom` 또는 `getMessages`로 최신 메시지 merge.
- 검증 기준:
- 실행 명령: `rg "loadMore|nextCursor|hasMore|merge|openRoom" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 기대 결과: pagination과 재동기화 로직이 확인된다.
- [x] **Task 3.5: lifecycle close/reconnect 구현**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 수정: `SodaLive/Sources/Settings/SettingsView.swift`
- 수정: `SodaLive/Sources/Settings/SignOut/SignOutViewModel.swift`
- 작업 내용:
- 화면 이탈 시 `leaveAndClose()`.
- `UIApplication.didEnterBackgroundNotification` 수신 시 `LEAVE_ROOM` 후 close.
- 로그아웃/전체 로그아웃/회원탈퇴 성공 직전에 세션 종료 notification을 post하고, ViewModel이 받아 close.
- socket 오류 발생 시 화면이 살아 있는 동안만 재연결한다.
- 검증 기준:
- 실행 명령: `rg "didEnterBackgroundNotification|leaveAndClose|userSession|reconnect" SodaLive/Sources/V2/Main/Chat/UserCreatorChat SodaLive/Sources/Settings`
- 기대 결과: 백그라운드/로그아웃/재연결 처리가 확인된다.
- [x] **Task 3.6: 음성 메시지 통신 계층 연결**
- 대상 파일:
- 확인/수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift`
- 확인/수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- `sendVoiceMessage(soundData:)` REST 호출 경로를 통신 계층에 준비한다.
- 이번 범위에서는 음성 메시지 송신 UI와 수신 음성 메시지 표시 UI를 연결하지 않는다.
- REST 응답의 `message`, `deliveredRealtime`, `pushSent`는 디코딩 가능해야 하며 debug log로 확인할 수 있게 둔다.
- 음성 메시지는 채팅방 메시지 목록 UI에 append/merge하지 않는다.
- 검증 기준:
- 실행 명령: `rg "sendVoiceMessage|deliveredRealtime|pushSent|voiceMessageFile" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
- 기대 결과: 음성 메시지 REST 통신 경로가 확인되고, 음성 UI/재생 컴포넌트 연결은 없어야 한다.
### Phase 4: UI 컴포넌트/화면
- [x] **Task 4.1: DM 메시지 컴포넌트 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatTextMessageItemView.swift`
- 작업 내용:
- mine 여부에 따라 좌/우 정렬.
- `TEXT``textMessage` 표시.
- `VOICE` 수신 메시지 표시와 재생 UI는 이번 범위에서 구현하지 않는다.
- pending/failed 상태를 표시한다.
- 검증 기준:
- 실행 명령: `rg "UserCreatorChatTextMessageItemView|pending|failed|textMessage" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components`
- 기대 결과: TEXT 렌더링과 pending/failed 상태 표시가 확인된다.
- 실행 명령: `rg "UserCreatorChatVoiceMessageItemView|voiceMessageUrl|AVAudioPlayer" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components`
- 기대 결과: 검색 결과가 없어야 한다.
- [x] **Task 4.2: DM 채팅방 화면 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
- 작업 내용:
- `BaseView(isLoading:)`로 전체 화면 Loading 표시.
- AI `ChatRoomView` 구조를 기준으로 헤더/메시지 목록/입력창 구성.
- 헤더에서 `Character / Clone`, 보유 캔 UI, 우측 더보기 버튼 제거.
- 입력창은 `socketState == .joined`일 때만 활성화.
- `onAppear`에서 `enter(roomId:)` 또는 `enter(creatorId:)` 호출.
- `onDisappear`에서 `leaveAndClose()`.
- 검증 기준:
- 실행 명령: `rg "BaseView|opponentNickname|opponentProfileImageUrl|socketState|onDisappear|sendText" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
- 기대 결과: Loading/헤더/입력/종료 처리가 확인된다.
- [x] **Task 4.3: I18n 문구 추가**
- 대상 파일:
- 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- DM 입력 placeholder.
- 연결 실패 토스트: `대화방에 접속하지 못했습니다.`
- 메시지 전송 실패 문구.
- 검증 기준:
- 실행 명령: `rg "UserCreatorChat|대화방에 접속하지 못했습니다" SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: 신규 문구가 다국어 `pick(ko:en:ja:)` 패턴으로 추가되어 있다.
### Phase 5: Routing/Deep Link/진입점
- [x] **Task 5.1: AppStep/ContentView 라우팅 추가**
- 대상 파일:
- 수정: `SodaLive/Sources/App/AppStep.swift`
- 수정: `SodaLive/Sources/ContentView.swift`
- 작업 내용:
- `case userCreatorChatRoom(roomId: Int)` 추가.
- `case userCreatorChatCreator(creatorId: Int)` 추가.
- 각 case를 `UserCreatorChatRoomView(roomId:)`, `UserCreatorChatRoomView(creatorId:)`에 연결.
- 검증 기준:
- 실행 명령: `rg "userCreatorChatRoom|userCreatorChatCreator|UserCreatorChatRoomView" SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`
- 기대 결과: roomId/creatorId 진입 route가 모두 확인된다.
- [x] **Task 5.2: 홈 채팅 탭 DM 아이템 라우팅 연결**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/MainChatView.swift`
- 작업 내용:
- `chatType == "DM"` 터치 시 `AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))`.
- `chatType == "AI"` 기존 `AppStep.chatRoom(id:)` 라우팅 유지.
- 검증 기준:
- 실행 명령: `rg "chatType == \"DM\"|userCreatorChatRoom|chatRoom\\(id:" SodaLive/Sources/V2/Main/Chat/MainChatView.swift`
- 기대 결과: AI/DM 라우팅 분기가 확인된다.
- [x] **Task 5.3: 크리에이터 채널 DM 시작 진입점 연결**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift`
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeRepository.swift`
- 작업 내용:
- 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가한다.
- `DM 보내기` 버튼 터치 시 로그인 여부만 확인한 뒤 `creatorId` 기반 route 또는 `rooms/create` 흐름으로 신규 DM 채팅방에 진입한다.
- AI 캐릭터 채팅 진입에 적용되는 본인인증/성인 콘텐츠 설정 guard는 DM 정책과 맞지 않으므로 적용하지 않는다.
- 기존 AI 채팅방 생성 API(`TalkApi.createChatRoom`)와 신규 DM 생성 API(`UserCreatorChatApi.createRoom`)가 섞이지 않게 분리한다.
- 검증 기준:
- 실행 명령: `rg "handleTalkTap|DM|userCreatorChatCreator|createRoom\\(creatorId" SodaLive/Sources/V2/CreatorChannel`
- 기대 결과: `handleTalkTap`은 AI 캐릭터 채팅 경로를 유지하고, 별도 `DM 보내기` 경로에서 `creatorId > 0` guard 후 DM 생성/조회 흐름으로 이동한다.
- [x] **Task 5.4: 푸시 deep link 파싱 추가**
- 대상 파일:
- 수정: `SodaLive/Sources/App/AppDeepLinkHandler.swift`
- 작업 내용:
- `AppDeepLinkAction.userCreatorChat(roomId: Int)` 추가.
- `voiceon://chat/{roomId}`, `voiceon-test://chat/{roomId}` 파싱.
- scheme은 기존 `voiceon`/`voiceon-test` 허용을 재사용하고, host `chat`, path 첫 segment를 `roomId`로 읽는다.
- apply 시 `AppStep.userCreatorChatRoom(roomId:)`로 이동한다.
- 검증 기준:
- 실행 명령: `rg "userCreatorChat|case \"chat\"|voiceon-test" SodaLive/Sources/App/AppDeepLinkHandler.swift`
- 기대 결과: chat deep link가 live/content/message와 별도 action으로 파싱된다.
### Phase 6: 프로젝트 등록/검증
- [x] **Task 6.1: Xcode 프로젝트 신규 파일 등록**
- 대상 파일:
- 수정: `SodaLive.xcodeproj/project.pbxproj`
- 작업 내용:
- Phase 1~5에서 추가한 `.swift` 파일을 앱 타깃 소스에 등록한다.
- 검증 기준:
- 실행 명령: `rg "UserCreatorChatRoomView|WebSocketChatClient|UserCreatorChatApi" SodaLive.xcodeproj/project.pbxproj`
- 기대 결과: 신규 Swift 파일들이 프로젝트에 등록되어 있다.
- [x] **Task 6.2: 정적 검색 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/**`
- 작업 내용:
- 제거 API 미사용 확인.
- STOMP/SockJS 미사용 확인.
- WebSocket outgoing guard 확인.
- 검증 기준:
- 실행 명령: `rg "/events|events/disconnect|messages/text|Stomp|SockJS|SUBSCRIBE|CONNECT" SodaLive/Sources`
- 기대 결과: 신규 유저-크리에이터 채팅 코드에서 제거 API/STOMP 사용이 없어야 한다.
- 실행 명령: `rg "roomId > 0|guard .*roomId" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
- 기대 결과: outgoing 명령 전 roomId guard가 확인된다.
- [x] **Task 6.3: 빌드 검증**
- 대상 파일:
- 확인: 전체 프로젝트
- 작업 내용:
- `docs/agent-guides/build-test-verification.md` 기준으로 빌드한다.
- 검증 기준:
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 기대 결과: `** BUILD SUCCEEDED **`
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: `** BUILD SUCCEEDED **`
- [ ] **Task 6.4: 수동 QA**
- 대상 파일:
- 확인: 앱 런타임
- 확인 항목:
- 방 생성/조회 후 `roomId > 0`으로만 `JOIN_ROOM`이 전송된다.
- `JOINED` 전에는 입력이 비활성화되고 Loading이 표시된다.
- `SEND_TEXT` outgoing JSON과 incoming raw text가 debug log에 남는다.
- `SEND_ACK` 수신 시 pending 메시지가 확정된다.
- `SEND_ACK` timeout 시 실패 상태가 보인다.
- `MESSAGE` 중복 수신 시 한 번만 표시된다.
- 백그라운드/뒤로가기/로그아웃 시 `LEAVE_ROOM` 후 socket이 닫힌다.
- `JOINED``ERROR` 3회 실패 시 토스트 후 이전 페이지로 이동한다.
- `voiceon://chat/{roomId}` deep link로 동일한 open/join 흐름이 실행된다.
- `VOICE` 메시지 송신/수신 UI와 재생 UI가 노출되지 않는다.
### Phase 7: 코드 리뷰 후속 수정
- [x] **Task 7.1: 재연결 동기화 및 VOICE 전용 페이지 보완**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- 재연결 후 최신 `hasMore``nextCursor`를 반영해 20개 초과 누락 구간을 과거 메시지 조회로 복구할 수 있게 한다.
- VOICE 메시지만 반환된 페이지는 cursor가 전진하는 동안 다음 페이지를 자동 조회한다.
- 검증 기준:
- 재연결 동기화 결과의 pagination 상태가 갱신된다.
- VOICE 전용 페이지 뒤의 TEXT 메시지까지 조회할 수 있다.
- [x] **Task 7.2: WebSocket 종료 및 재연결 단일 실행 보장**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- 동일 socket task의 `receive` 실패와 delegate close가 중복 종료 이벤트를 만들지 않게 한다.
- 예약된 재연결 작업은 항상 하나만 유지하고 화면 이탈 또는 연결 성공 시 취소한다.
- 검증 기준:
- socket 종료 처리 후 현재 task가 해제된다.
- 중복 `.closed` 이벤트가 들어와도 재연결 예약은 하나만 남는다.
- [x] **Task 7.3: 후속 수정 검증**
- 대상 파일:
- 확인: 전체 프로젝트
- 검증 기준:
- `git diff --check`가 통과한다.
- `SodaLive`, `SodaLive-dev` Debug 빌드가 성공한다.
### Phase 8: 코드 리뷰 결과 반영
- [x] **Task 8.1: 백그라운드 재연결과 openRoom 성공 gate 보완**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- 백그라운드 진입 시 예약된 재연결을 취소하고 앱 활성 상태를 별도로 관리한다.
- `openRoom` 성공 전에는 foreground 전환이나 재연결 경로에서 WebSocket을 연결하지 않는다.
- 백그라운드에서 `openRoom` 응답이 완료되면 방 정보만 반영하고 socket 연결은 foreground까지 미룬다.
- 검증 기준:
- `openRoom` 성공 전이거나 앱이 백그라운드인 동안 `socketClient.connect` 경로가 차단된다.
- foreground 복귀 시 성공적으로 열린 방에 한해 재연결한다.
- [x] **Task 8.2: ACK 유실 시 pending 메시지 재동기화**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- REST/실시간 서버 메시지 merge 시 동일한 내 텍스트 pending/failed 메시지를 1:1로 재동기화한다.
- ACK만 유실된 경우 서버 메시지와 failed pending이 중복 노출되지 않게 한다.
- 검증 기준:
- 서버 `messageId` dedupe와 로컬 pending 재동기화가 함께 적용된다.
- 동일 텍스트를 연속 전송해도 서버 메시지 하나가 pending 하나만 확정한다.
- [x] **Task 8.3: WebSocket mutable state 직렬화**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
- 작업 내용:
- public 명령과 `URLSessionWebSocketDelegate`/receive/send callback의 `task`, `session`, `state`, `isClosing` 접근을 main thread로 직렬화한다.
- 검증 기준:
- socket open/close/receive가 겹쳐도 현재 task 판별과 종료 이벤트가 단일 실행된다.
- [x] **Task 8.4: 요청 범위 외 변경 원복**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift`
- 작업 내용:
- DM 채팅 구현과 관계없는 기존 `print(error)` 삭제 2건을 원복한다.
- 검증 기준:
- 두 파일이 DM 채팅 변경 diff에서 제외된다.
- [x] **Task 8.5: 리뷰 반영 검증**
- 대상 파일:
- 확인: 전체 프로젝트
- 검증 기준:
- `git diff --check HEAD`가 통과한다.
- `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과한다.
- `SodaLive`, `SodaLive-dev` Debug 빌드가 성공한다.
- [x] **Task 8.6: 최신 메시지 REST 재동기화 Loading/오류 처리**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- `syncLatestMessages` REST 요청 동안 Loading을 유지한다.
- 요청 실패, API 실패 응답, 디코딩 오류를 기존 토스트 경로로 전달한다.
- 검증 기준:
- `JOINED` 직후 Loading은 재동기화 완료 후 해제된다.
- 재동기화 실패 시 Loading이 해제되고 오류 토스트가 표시된다.
### Phase 9: 추가 코드 리뷰 결과 반영
- [x] **Task 9.1: JOIN 전송 실패 및 JOINED timeout 처리**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/UserCreatorChatWebSocketModels.swift`
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- `JOIN_ROOM` 전송 오류를 ViewModel의 JOIN 재시도 정책으로 전달한다.
- `JOIN_ROOM` 전송 후 15초 안에 `JOINED`가 없으면 JOIN 실패 event를 전달한다.
- 연결 성공, socket 종료, 재연결 시작 시 예약된 JOIN timeout을 취소한다.
- 검증 기준:
- JOIN 전송 오류 또는 timeout이 발생하면 `.joining`에 고정되지 않고 최대 3회 재시도한다.
- 화면 이탈 또는 연결 성공 후 이전 JOIN timeout이 실행되지 않는다.
- [x] **Task 9.2: ACK 유실 재동기화 clock-skew 허용**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
- 작업 내용:
- 동일한 내 텍스트 서버 메시지와 pending/failed 메시지를 매칭할 때 서버 시각이 로컬 생성 시각보다 최대 5분 이전인 경우까지 허용한다.
- 기존 1:1 매칭과 ACK timeout 취소 동작을 유지한다.
- 검증 기준:
- 서버 `createdAt`이 로컬 pending 생성 시각보다 5분 이내로 이전이어도 중복 pending/failed 메시지가 제거된다.
- 5분을 초과해 오래된 동일 텍스트 메시지는 pending과 매칭하지 않는다.
- [x] **Task 9.3: 추가 리뷰 반영 검증**
- 대상 파일:
- 확인: 전체 프로젝트
- 검증 기준:
- `git diff --check HEAD`가 통과한다.
- `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과한다.
- `SodaLive`, `SodaLive-dev` Debug 빌드가 성공한다.
---
## 검증 기록
- 2026-07-11 문서 작성: `docs/agent-guides/documentation-policy.md`, 기존 홈 채팅 탭 PRD/계획 문서, 기존 AI `ChatRoomView`/`ChatRoomViewModel`, V2 `MainChatView`, `AppDeepLinkHandler`, `BaseView`, 기존 multipart 음성 메시지 전송 패턴을 확인하고 PRD 및 구현 계획에 반영했다. 이 기록은 구현 전 문서 작성 시점의 상태다.
- 2026-07-11 문서 보완: 사용자 확정 사항을 반영해 기존 `handleTalkTap`은 AI 캐릭터 채팅 진입으로 유지하고, 별도 `DM 보내기` 버튼에서 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동하도록 PRD/계획을 수정했다. 또한 `VOICE`는 DTO/API/Repository 통신 작업만 범위에 남기고, 음성 메시지 송신 UI·수신 표시 UI·재생 UI 작업을 제외하도록 Phase 3/4/5와 QA 항목을 갱신했다.
- 2026-07-11 코드 리뷰/검증: 변경사항 기준으로 제거된 event/text REST API 및 STOMP/SockJS 미사용, WebSocket outgoing `roomId > 0` guard, 신규 파일 프로젝트 등록을 정적 검색으로 확인했다. `SodaLive`, `SodaLive-dev` Debug simulator 빌드가 성공했다.
- 2026-07-12 코드 리뷰 후속 수정: 재연결 후 `openRoom`의 최신 pagination 상태를 반영하고, VOICE 전용 페이지에서 cursor가 전진하는 동안 다음 페이지를 자동 조회하도록 수정했다. 동일 WebSocket task의 종료 이벤트와 ViewModel의 재연결 예약을 각각 한 번만 처리하도록 보완했다. `git diff --check`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewDerivedData CODE_SIGNING_ALLOWED=NO build``SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
- 2026-07-12 코드 리뷰 결과 반영: 백그라운드 상태와 `openRoom` 성공 여부를 별도 gate로 관리해 지연된 REST 응답과 예약된 재연결이 백그라운드에서 socket을 다시 열지 않게 했다. 새로 관측된 내 서버 텍스트 메시지는 로컬 pending/failed 메시지와 1:1로 재동기화하고, WebSocket의 public 명령과 delegate/receive/send callback에서 발생하는 mutable state 접근을 main thread로 직렬화했다. DM 채팅과 관계없는 기존 `print(error)` 삭제 2건은 원복했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveCodeReviewDerivedData CODE_SIGNING_ALLOWED=NO build``SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
- 2026-07-12 최신 메시지 REST 재동기화 보완: `JOINED``syncLatestMessages` 완료까지 Loading을 유지하고, 요청 실패·API 실패 응답·디코딩 오류를 `applyRestFailure` 토스트 경로로 전달하도록 수정했다. `git diff --check HEAD`가 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug CODE_SIGNING_ALLOWED=NO build``SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다.
- 2026-07-12 추가 코드 리뷰 결과 반영: `JOIN_ROOM` 전송 오류를 즉시 JOIN 실패 event로 전달하고, 15초 동안 `JOINED`가 없으면 현재 socket을 닫은 뒤 기존 최대 3회 재시도 정책을 적용하도록 보완했다. ACK 유실 재동기화 시 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과했고, `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewCurrent CODE_SIGNING_ALLOWED=NO build``SodaLive-dev` 동일 명령이 종료 코드 0으로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.

View File

@@ -0,0 +1,229 @@
# PRD: 유저-크리에이터 1:1 채팅
## 1. Overview
유저와 크리에이터가 1:1로 대화할 수 있는 DM 채팅방을 iOS 앱에 추가한다. REST API는 기존 `BASE_URL` 기반 Moya `TargetType`로 호출하고, 실시간 텍스트 채팅은 STOMP/SockJS 없이 `URLSessionWebSocketTask` 기반 raw WebSocket으로 처리한다.
---
## 2. Problem
- 기존 홈 채팅 탭의 `DM` 아이템은 목록에는 노출되지만 DM 채팅방 진입 화면이 연결되어 있지 않다.
- 기존 AI 채팅방(`ChatRoomView`)은 REST 기반 텍스트 전송 API를 사용하므로, raw WebSocket 기반 유저-크리에이터 채팅 계약과 다르다.
- 서버는 `/ws/v2/user-creator-chat` raw WebSocket만 지원하며, STOMP frame(`CONNECT`/`SUBSCRIBE`/`SEND`)을 보내면 안 된다.
- WebSocket 연결 완료와 채팅방 입장 완료가 다르므로, UI의 연결 완료 기준을 socket open이 아니라 `JOINED` 수신으로 분리해야 한다.
---
## 3. Goals
- 유저-크리에이터 채팅 REST API 4종을 Moya `TargetType`에 추가하고, 요청/응답 `Codable` DTO를 정의한다.
- WebSocket은 `URLSessionWebSocketTask`로 구현하고, 모든 요청에 `Authorization: Bearer {accessToken}` 헤더를 넣는다.
- `openRoom` 성공 후 실제 `roomId > 0`일 때만 WebSocket 연결 및 `JOIN_ROOM`을 수행한다.
- WebSocket 상태를 `disconnected`, `connecting`, `socketOpen`, `joining`, `joined` 등으로 분리하고, UI는 `JOINED` 수신 후에만 입력 가능 상태로 본다.
- `JOINED` 이후에만 `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 허용한다.
- 텍스트 전송은 REST API가 아니라 WebSocket `SEND_TEXT`로만 처리한다.
- `SEND_TEXT` 전송 시 pending 메시지를 즉시 표시하고, `SEND_ACK.requestId`로 매칭해 서버 메시지로 확정한다.
- `SEND_ACK` timeout은 15초 기준으로 실패 상태를 표시한다.
- `MESSAGE` 수신 및 REST 재조회 결과는 `messageId` 기준으로 중복 제거/merge한다.
- joined 상태에서 30초 주기로 `PING`을 보내고 `PONG`을 처리한다.
- 화면 이탈, 앱 백그라운드, 로그아웃/회원탈퇴 시 `LEAVE_ROOM` 전송 후 WebSocket을 닫는다.
- 네트워크 오류 시 사용자가 채팅방 화면에 머무는 동안만 재연결하고, 재연결 후 `JOIN_ROOM`을 다시 보낸다.
- `JOINED``ERROR`가 내려오면 최대 3회 재시도하고, 계속 실패하면 Loading을 숨긴 뒤 토스트를 표시하고 이전 페이지로 이동한다.
- 푸시 deep link `voiceon://chat/{roomId}` / `voiceon-test://chat/{roomId}`를 파싱해 일반 진입과 동일하게 `openRoom` 후 WebSocket `JOIN_ROOM`을 수행한다.
- UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다.
- 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다.
- `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다.
---
## 4. Non-Goals
- STOMP/SockJS 클라이언트 추가 또는 STOMP frame 전송은 하지 않는다.
- 텍스트 메시지 전송에 REST `POST /messages/text`를 사용하지 않는다.
- 제거된 event API를 사용하지 않는다.
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events`
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`
- 기존 AI 채팅방의 API/동작을 유저-크리에이터 채팅 방식으로 변경하지 않는다.
- 크리에이터 채널의 기존 AI 캐릭터 채팅 진입(`handleTalkTap`)을 DM 진입으로 바꾸지 않는다.
- 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI는 이번 범위에서 구현하지 않는다.
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
- 서버 API 계약 변경은 포함하지 않는다.
---
## 5. Target Users
- 크리에이터와 1:1 DM 대화를 시작하거나 이어서 확인하려는 일반 사용자
- 푸시 알림을 통해 특정 DM 채팅방으로 바로 진입하려는 사용자
---
## 6. User Stories
- 사용자는 크리에이터와의 DM 채팅방에 진입하면 최신 메시지와 과거 메시지를 확인할 수 있다.
- 사용자는 채팅방 연결이 완료되기 전까지 Loading 상태를 확인하고, `JOINED` 후 메시지를 입력할 수 있다.
- 사용자는 텍스트를 전송하면 즉시 pending 메시지를 보고, 서버 ACK 이후 확정된 메시지 상태를 확인할 수 있다.
- 사용자는 상대방이 보낸 실시간 메시지를 중복 없이 목록에서 확인할 수 있다.
- 사용자는 과거 메시지를 스크롤로 추가 조회할 수 있다.
- 사용자는 푸시 알림을 눌러 `voiceon://chat/{roomId}` 형식의 deep link로 채팅방에 진입할 수 있다.
---
## 7. Core Requirements
### 7.1 REST API
신규 REST API는 Moya `TargetType`로 추가한다. 모든 요청은 기존 REST `BASE_URL`을 사용하고 `Authorization` 헤더를 포함한다.
1. 방 생성/조회
- `POST /api/v2/user-creator-chat/rooms/create`
- body: `{"creatorId": 123}`
- response: `ApiResponse<CreateRoomResponse>`
- data: `{"roomId": 10}`
2. 채팅방 열기
- `GET /api/v2/user-creator-chat/rooms/{roomId}/open?limit=20`
- data: `roomId`, `opponentNickname`, `opponentProfileImageUrl`, `messages`, `hasMore`, `nextCursor`
3. 과거 메시지 조회
- `GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20`
- data: `messages`, `hasMore`, `nextCursor`
4. 음성 메시지 전송
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
- `multipart/form-data`
- parts:
- `voiceMessageFile`: file data
- `request`: JSON 문자열 `{"recipientId": null}`
- data: `message`, `deliveredRealtime`, `pushSent`
### 7.2 DTO
공통 응답은 기존 `ApiResponse<T>`를 사용한다.
`MessageItem`은 아래 필드를 `Codable`로 정의한다.
```json
{
"messageId": 1,
"messageType": "TEXT",
"mine": true,
"createdAt": 1710000000000,
"textMessage": "hello",
"voiceMessageUrl": null,
"senderId": 1,
"senderNickname": "...",
"senderProfileImageUrl": "..."
}
```
로컬 pending/failed 상태는 서버 DTO와 분리한 display model 또는 wrapper에서 관리한다.
### 7.3 WebSocket URL/Auth
- WebSocket URL은 REST `BASE_URL`의 scheme/host를 기준으로 만든다.
- REST가 `http://...`이면 `ws://{host}/ws/v2/user-creator-chat`
- REST가 `https://...`이면 `wss://{host}/ws/v2/user-creator-chat`
- WebSocket `URLRequest``Authorization: Bearer {accessToken}` 헤더를 넣는다.
- STOMP 관련 라이브러리와 STOMP frame은 사용하지 않는다.
### 7.4 WebSocket Protocol
`roomId`가 nil 또는 0이면 `JOIN_ROOM`, `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 절대 보내지 않는다.
Client -> Server:
- `JOIN_ROOM`: `{"type":"JOIN_ROOM","requestId":"uuid","roomId":10,"payload":{}}`
- `SEND_TEXT`: `{"type":"SEND_TEXT","requestId":"uuid","roomId":10,"payload":{"textMessage":"hello"}}`
- `LEAVE_ROOM`: `{"type":"LEAVE_ROOM","requestId":"uuid","roomId":10,"payload":{}}`
- `PING`: `{"type":"PING","requestId":"uuid","roomId":10,"payload":{}}`
Server -> Client:
- `JOINED`: 연결 완료 기준
- `SEND_ACK`: `requestId`로 pending 메시지 확정
- `MESSAGE`: `messageId` 기준 중복 제거 후 append
- `PONG`: ping 응답 처리
- `ERROR`: root code/message가 아니라 `payload.messageKey`를 읽는다.
모든 outgoing JSON과 incoming raw text는 `DEBUG_LOG`로 남긴다.
### 7.5 Connection State
- `disconnected`: task 없음 또는 닫힘
- `connecting`: WebSocket task 생성/resume 중
- `socketOpen`: WebSocket task가 열렸지만 room join 전
- `joining`: `JOIN_ROOM` 전송 후 `JOINED` 대기
- `joined`: 채팅 입력/핑/leave 허용
UI 입력 가능, heartbeat 시작, pending send 허용 기준은 `socketOpen`이 아니라 `joined`다.
- `JOIN_ROOM` 전송 실패는 즉시 JOIN 재시도 정책으로 전달한다.
- `JOIN_ROOM` 전송 후 15초 안에 `JOINED`가 없으면 JOIN 실패로 처리하고 동일한 재시도 정책을 적용한다.
### 7.6 Message Behavior
- `openRoom` 응답 메시지는 정렬 후 초기 목록으로 표시한다.
- 과거 메시지는 `messages` API로 조회하고, `messageId` 기준 merge한다.
- `SEND_TEXT``UUID` requestId를 만들고 임시 pending 메시지를 append한다.
- `SEND_ACK` 수신 시 pending 메시지를 서버 `MessageItem`으로 교체한다.
- 15초 안에 `SEND_ACK`가 없으면 해당 pending 메시지를 failed 상태로 전환한다.
- `MESSAGE` 수신 시 이미 동일 `messageId`가 있으면 무시한다.
- 재연결 후 최신 동기화가 필요하면 `openRoom` 또는 `messages` API 결과를 `messageId` 기준으로 merge한다.
- ACK 유실 시 동일한 내 텍스트의 서버 메시지와 pending/failed 메시지를 1:1로 재동기화한다.
- 서버/클라이언트 시각 차이로 중복 메시지가 남지 않도록 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용한다.
### 7.7 Loading/Error
- 네트워크 통신 중에는 항상 Loading을 표시한다.
- 최초 진입은 `openRoom` + WebSocket `JOINED`까지 Loading을 유지한다.
- `JOINED``ERROR` 수신 시 최대 3회 재시도한다.
- `JOIN_ROOM` 전송 실패 또는 15초 `JOINED` timeout도 동일하게 최대 3회 재시도한다.
- 3회 재시도 후에도 실패하면 Loading을 숨기고 `대화방에 접속하지 못했습니다.` 토스트를 표시한 뒤 socket을 닫고 이전 페이지로 이동한다.
- 일반 REST 실패도 Loading을 숨기고 토스트를 표시한다.
### 7.8 Lifecycle/Reconnection
- 화면 이탈 시 `LEAVE_ROOM`을 보내고 WebSocket을 닫는다.
- 앱 백그라운드 전환 시 `LEAVE_ROOM` 후 close/cancel한다.
- 로그아웃/전체 로그아웃/회원탈퇴 성공 흐름에서는 열린 DM WebSocket이 `LEAVE_ROOM`을 보낸 뒤 닫히도록 알림 또는 공통 세션 종료 훅을 둔다.
- 네트워크 오류로 socket이 끊기면 채팅방 화면이 살아 있는 동안만 재연결한다.
- 재연결 후에는 동일 `roomId`로 다시 `JOIN_ROOM`을 보낸다.
### 7.9 UI
- 신규 DM 채팅방은 AI `ChatRoomView`와 같은 전체 화면 채팅 UX를 따른다.
- 헤더에서는 아래 3가지를 제거한다.
- `Character / Clone` 표시
- 우측 더보기 버튼
- 더보기 버튼 왼쪽의 보유 캔 표시 UI
- 헤더에는 뒤로가기, 상대 프로필 이미지, 상대 닉네임을 표시한다.
- 메시지 목록 UI는 이번 범위에서 `TEXT`만 표시한다.
- `VOICE` 메시지는 DTO/API/Repository 통신 처리를 추가하되, 채팅방 화면에는 표시하지 않는다.
- 입력창은 `joined` 상태에서만 활성화한다.
- 기존 AI 채팅방의 quota, locked image, 배경 변경/초기화 설정은 DM 화면에 넣지 않는다.
### 7.10 Deep Link
- `voiceon://chat/{roomId}``voiceon-test://chat/{roomId}`를 지원한다.
- URL 파싱 기준:
- scheme: `voiceon` 또는 `voiceon-test`
- host: `chat`
- path 첫 segment: `roomId`
- 푸시 터치로 roomId를 얻으면 일반 진입과 동일하게 `openRoom(roomId)` 성공 후 WebSocket connect 및 `JOIN_ROOM`을 수행한다.
---
## 8. UX / UI Expectations
- 화면 구조, spacing, 색상, 입력창은 기존 `ChatRoomView`의 사용자 경험을 우선 재사용한다.
- Loading은 기존 `BaseView(isLoading:)` 또는 동일한 `LoadingView` 패턴을 사용한다.
- 토스트는 기존 `.sodaToast` 패턴을 사용한다.
- pending 메시지는 사용자가 전송 즉시 인지할 수 있어야 하고, 실패 상태는 재전송 가능 여부를 후속 결정할 수 있도록 명확히 표시한다.
---
## 9. Technical Constraints
- 신규 View/ViewModel/Repository/DTO는 새 기능이므로 `SodaLive/Sources/V2/**` 아래에 둔다.
- DM 채팅방 전용 컴포넌트는 해당 기능 폴더 하위 `Components`에 둔다.
- Moya는 REST API에만 사용한다.
- 실시간 채팅에는 `URLSessionWebSocketTask`를 사용한다. 현재 소스에서 별도 raw WebSocket 클라이언트는 확인되지 않았다.
- `BASE_URL`, `UserDefaultsKey.token`, `ApiResponse<T>`, `DEBUG_LOG`, `ERROR_LOG`, `BaseView`, `.sodaToast`, `AppState`/`AppStep`의 기존 패턴을 따른다.
- `roomId == 0`을 전송하면 서버가 `ERROR` 후 close code `1008`로 닫으므로 모든 outgoing 명령은 `roomId > 0` guard를 둔다.
---
## 10. Assumptions
- 문서 작성 기준일은 2026-07-11이며 신규 문서 폴더도 이 날짜를 사용한다.
- 기존 대화 목록(`MainChatView`)의 `chatType == "DM"` 아이템은 신규 DM 채팅방으로 라우팅한다.
---
## 11. Confirmed Decisions
- 2026-07-11: 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
- 2026-07-11: 크리에이터 채널에 별도 `DM 보내기` 버튼을 추가하고, 해당 버튼 터치 시 `creatorId` 기반 DM 생성/조회 후 신규 DM 채팅방으로 이동한다.
- 2026-07-11: 이번 범위에서 `VOICE`는 통신 작업만 구현한다. 음성 메시지 송신 UI와 수신 음성 메시지 표시 UI는 구현하지 않는다.
- 2026-07-11: `DM 보내기` 진입은 로그인 여부만 확인한다. AI 캐릭터 채팅 진입에 적용되는 본인인증/성인 콘텐츠 설정 guard는 DM 정책과 맞지 않으므로 적용하지 않는다.