diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index efdd4e3b..01295188 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -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 = ""; }; C0A740000000000000000006 /* MainChatRoomListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatRoomListItem.swift; sourceTree = ""; }; C0A740000000000000000007 /* MainChatFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatFilter.swift; sourceTree = ""; }; + C0A740000000000000000008 /* UserCreatorChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatModels.swift; sourceTree = ""; }; + C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatDisplayMessage.swift; sourceTree = ""; }; + C0A74000000000000000000A /* UserCreatorChatApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatApi.swift; sourceTree = ""; }; + C0A74000000000000000000B /* UserCreatorChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRepository.swift; sourceTree = ""; }; + C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatWebSocketModels.swift; sourceTree = ""; }; + C0A74000000000000000000D /* WebSocketChatClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketChatClient.swift; sourceTree = ""; }; + C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatTextMessageItemView.swift; sourceTree = ""; }; + C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRoomViewModel.swift; sourceTree = ""; }; + C0A740000000000000000010 /* UserCreatorChatRoomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRoomView.swift; sourceTree = ""; }; + C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatSessionNotification.swift; sourceTree = ""; }; C1165E3F4CA1AB019FC85067 /* LanguageRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageRepository.swift; sourceTree = ""; }; C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingIconButton.swift; sourceTree = ""; }; C6B18C00DE256DCC53030630 /* Spacing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Spacing.swift; sourceTree = ""; }; @@ -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 = ""; }; + 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 = ""; + }; + C0A74000000000000000000105 /* Models */ = { + isa = PBXGroup; + children = ( + C0A740000000000000000008 /* UserCreatorChatModels.swift */, + C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */, + ); + path = Models; + sourceTree = ""; + }; + C0A74000000000000000000106 /* Repository */ = { + isa = PBXGroup; + children = ( + C0A74000000000000000000A /* UserCreatorChatApi.swift */, + C0A74000000000000000000B /* UserCreatorChatRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + C0A74000000000000000000107 /* WebSocket */ = { + isa = PBXGroup; + children = ( + C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */, + C0A74000000000000000000D /* WebSocketChatClient.swift */, + ); + path = WebSocket; + sourceTree = ""; + }; + C0A74000000000000000000108 /* Components */ = { + isa = PBXGroup; + children = ( + C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */, + ); + path = Components; + sourceTree = ""; + }; 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 */, diff --git a/SodaLive/Sources/App/AppDeepLinkHandler.swift b/SodaLive/Sources/App/AppDeepLinkHandler.swift index bb85faa0..24b6171e 100644 --- a/SodaLive/Sources/App/AppDeepLinkHandler.swift +++ b/SodaLive/Sources/App/AppDeepLinkHandler.swift @@ -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 diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index ea03dd84..d39450bb 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -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) diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 21430f5c..96309b44 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -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() diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 75100b82..9120f8eb 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -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: "ライブを作成") } diff --git a/SodaLive/Sources/Settings/SettingsView.swift b/SodaLive/Sources/Settings/SettingsView.swift index a96f2d28..7c8ecba9 100644 --- a/SodaLive/Sources/Settings/SettingsView.swift +++ b/SodaLive/Sources/Settings/SettingsView.swift @@ -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 diff --git a/SodaLive/Sources/Settings/SignOut/SignOutViewModel.swift b/SodaLive/Sources/Settings/SignOut/SignOutViewModel.swift index 523e5666..52cbb816 100644 --- a/SodaLive/Sources/Settings/SignOut/SignOutViewModel.swift +++ b/SodaLive/Sources/Settings/SignOut/SignOutViewModel.swift @@ -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) diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift index 5b6ac85b..d75ceb41 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift @@ -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) diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index a486c091..d06f4bd7 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -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)) diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift index 2299b054..4c066ce4 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift @@ -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) diff --git a/SodaLive/Sources/V2/Main/Chat/MainChatView.swift b/SodaLive/Sources/V2/Main/Chat/MainChatView.swift index 2293d1e1..9690b80e 100644 --- a/SodaLive/Sources/V2/Main/Chat/MainChatView.swift +++ b/SodaLive/Sources/V2/Main/Chat/MainChatView.swift @@ -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)) + } } } diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatTextMessageItemView.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatTextMessageItemView.swift new file mode 100644 index 00000000..2eab895d --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatTextMessageItemView.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDisplayMessage.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDisplayMessage.swift new file mode 100644 index 00000000..a238f834 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDisplayMessage.swift @@ -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 + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift new file mode 100644 index 00000000..deca85fc --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift @@ -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 +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatApi.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatApi.swift new file mode 100644 index 00000000..bc58c8e2 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatApi.swift @@ -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))"] + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift new file mode 100644 index 00000000..0f4af349 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift @@ -0,0 +1,30 @@ +import Foundation +import Combine +import CombineMoya +import Moya + +final class UserCreatorChatRepository { + private let api = MoyaProvider() + + func createRoom(creatorId: Int) -> AnyPublisher { + return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(creatorId: creatorId))) + } + + func openRoom(roomId: Int, limit: Int) -> AnyPublisher { + return api.requestPublisher(.openRoom(roomId: roomId, limit: limit)) + } + + func getMessages(roomId: Int, cursor: Int?, limit: Int) -> AnyPublisher { + return api.requestPublisher(.getMessages(roomId: roomId, cursor: cursor, limit: limit)) + } + + func sendVoiceMessage(roomId: Int, voiceData: Data) -> AnyPublisher { + 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)) + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift new file mode 100644 index 00000000..890e4839 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift new file mode 100644 index 00000000..454deb7c --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift @@ -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() + 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.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.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.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.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.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 + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatSessionNotification.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatSessionNotification.swift new file mode 100644 index 00000000..66c801c3 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatSessionNotification.swift @@ -0,0 +1,5 @@ +import Foundation + +extension Notification.Name { + static let userCreatorChatSessionClose = Notification.Name("userCreatorChatSessionClose") +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/UserCreatorChatWebSocketModels.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/UserCreatorChatWebSocketModels.swift new file mode 100644 index 00000000..41af96f9 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/UserCreatorChatWebSocketModels.swift @@ -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 + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift new file mode 100644 index 00000000..5686f246 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift @@ -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(_ 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) + } + } +} diff --git a/docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md b/docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md new file mode 100644 index 00000000..b51b502a --- /dev/null +++ b/docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md @@ -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`: `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에 남겨 두었다. diff --git a/docs/20260711_유저_크리에이터_1대1_채팅/prd.md b/docs/20260711_유저_크리에이터_1대1_채팅/prd.md new file mode 100644 index 00000000..38042c86 --- /dev/null +++ b/docs/20260711_유저_크리에이터_1대1_채팅/prd.md @@ -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` + - 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`를 사용한다. + +`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`, `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 정책과 맞지 않으므로 적용하지 않는다.