From 8eca5df62b46206b57ba5d1d2083c542ac89a390 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 20 Mar 2026 10:51:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(live-room):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=A3=B8=20=EC=B1=84=ED=8C=85=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/I18n/I18n.swift | 1 + .../Sources/Live/Room/Chat/LiveRoomChat.swift | 2 + .../Live/Room/Chat/LiveRoomChatItemView.swift | 4 + .../Room/Chat/LiveRoomChatRawMessage.swift | 4 +- ...LiveRoomRouletteDonationChatItemView.swift | 2 +- .../Sources/Live/Room/LiveRoomViewModel.swift | 257 ++++++++++++++++-- .../V2/Component/View/LiveRoomChatView.swift | 11 +- .../Sources/Live/Room/V2/LiveRoomViewV2.swift | 41 ++- docs/20260319_라이브룸채팅삭제기능구현계획.md | 138 ++++++++++ 9 files changed, 433 insertions(+), 27 deletions(-) create mode 100644 docs/20260319_라이브룸채팅삭제기능구현계획.md diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 5e20a22..f93f100 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -787,6 +787,7 @@ enum I18n { static var chatFreezeOnStatusMessage: String { pick(ko: "채팅창을 얼렸습니다.", en: "Chat has been frozen.", ja: "チャットが凍結されました。") } static var chatFreezeOffStatusMessage: String { pick(ko: "채팅창 얼림이 해제되었습니다.", en: "Chat freeze has been lifted.", ja: "チャット凍結が解除されました。") } static var chatFreezeBlockedMessage: String { pick(ko: "채팅창이 얼려져 있어 채팅할 수 없습니다.", en: "You cannot chat while chat is frozen.", ja: "チャットが凍結中のため送信できません。") } + static var chatDeleteTitle: String { pick(ko: "채팅 삭제", en: "Delete chat", ja: "チャット削除") } } enum LiveNow { diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift index 79cca09..fa3d909 100644 --- a/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift @@ -16,6 +16,7 @@ protocol LiveRoomChat { } struct LiveRoomNormalChat: LiveRoomChat { + let chatId: String let userId: Int let profileUrl: String let nickname: String @@ -37,6 +38,7 @@ struct LiveRoomDonationChat: LiveRoomChat { } struct LiveRoomRouletteDonationChat: LiveRoomChat { + let memberId: Int let profileUrl: String let nickname: String let rouletteResult: String diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift index 0f15838..4572f03 100644 --- a/SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift @@ -12,6 +12,7 @@ struct LiveRoomChatItemView: View { let chatMessage: LiveRoomNormalChat let onClickProfile: () -> Void + let onLongPressChat: (() -> Void)? private var rankValue: Int { chatMessage.rank + 1 @@ -132,6 +133,9 @@ struct LiveRoomChatItemView: View { Color.black.opacity(0.6) ) .cornerRadius(3.3) + .onLongPressGesture { + onLongPressChat?() + } } .frame(width: screenSize().width - 86, alignment: .leading) .padding(.leading, 20) diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift index 2f7653f..80bb386 100644 --- a/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift @@ -10,7 +10,7 @@ import Foundation struct LiveRoomChatRawMessage: Codable { enum LiveRoomChatRawMessageType: String, Codable { case DONATION, SECRET_DONATION, EDIT_ROOM_INFO, SET_MANAGER, TOGGLE_ROULETTE, TOGGLE_CHAT_FREEZE, ROULETTE_DONATION - case HEART_DONATION, BIG_HEART_DONATION + case HEART_DONATION, BIG_HEART_DONATION, NORMAL_CHAT, DELETE_CHAT, DELETE_CHAT_BY_USER } let type: LiveRoomChatRawMessageType @@ -21,4 +21,6 @@ struct LiveRoomChatRawMessage: Codable { let donationMessage: String? var isActiveRoulette: Bool? = nil var isChatFrozen: Bool? = nil + var chatId: String? = nil + var targetUserId: Int? = nil } diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomRouletteDonationChatItemView.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomRouletteDonationChatItemView.swift index b8abbe7..3d622de 100644 --- a/SodaLive/Sources/Live/Room/Chat/LiveRoomRouletteDonationChatItemView.swift +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomRouletteDonationChatItemView.swift @@ -66,6 +66,6 @@ struct LiveRoomRouletteDonationChatItemView: View { struct LiveRoomRouletteDonationChatItemView_Previews: PreviewProvider { static var previews: some View { - LiveRoomRouletteDonationChatItemView(chatMessage: LiveRoomRouletteDonationChat(profileUrl: "", nickname: "유저일", rouletteResult: "옵션1")) + LiveRoomRouletteDonationChatItemView(chatMessage: LiveRoomRouletteDonationChat(memberId: 0, profileUrl: "", nickname: "유저일", rouletteResult: "옵션1")) } } diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift index 239ab61..d6415df 100644 --- a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -318,6 +318,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject { return isChatFrozen && liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) } + private var isCreator: Bool { + liveRoomInfo?.creatorId == UserDefaults.int(forKey: .userId) + } + func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) { guard isV2VJoined else { return } stopV2VTranslation(clearCaptionText: clearCaptionText) @@ -695,21 +699,162 @@ final class LiveRoomViewModel: NSObject, ObservableObject { } else if isNoChatting { self.popupContent = "\(remainingNoChattingTime)초 동안 채팅하실 수 없습니다" self.isShowPopup = true - } else if chatMessage.count > 0 { - agora.sendMessageToGroup(textMessage: chatMessage) { _, error in + } else if !chatMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let chatId = UUID().uuidString + let chatRawMessage = LiveRoomChatRawMessage( + type: .NORMAL_CHAT, + message: chatMessage, + can: 0, + donationMessage: nil, + chatId: chatId + ) + + agora.sendRawMessageToGroup(rawMessage: chatRawMessage) { _, error in if error == nil { let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId)) let rank = self.getUserRank(userId: UserDefaults.int(forKey: .userId)) - self.messages.append(LiveRoomNormalChat(userId: UserDefaults.int(forKey: .userId), profileUrl: profileUrl, nickname: nickname, rank: rank, chat: chatMessage)) - + self.messages.append( + LiveRoomNormalChat( + chatId: chatId, + userId: UserDefaults.int(forKey: .userId), + profileUrl: profileUrl, + nickname: nickname, + rank: rank, + chat: chatMessage + ) + ) + self.invalidateChat() } - + onSuccess() } } } } + + func deleteChat(_ chat: LiveRoomNormalChat) { + guard isCreator else { + return + } + + agora.sendRawMessageToGroup( + rawMessage: LiveRoomChatRawMessage( + type: .DELETE_CHAT, + message: chat.chat, + can: 0, + donationMessage: nil, + chatId: chat.chatId, + targetUserId: chat.userId + ), + completion: { [unowned self] _, error in + if error == nil { + let previousCount = self.messages.count + self.applyDeleteChat(chatId: chat.chatId) + + if previousCount == self.messages.count { + self.applyDeleteChatFallback(userId: chat.userId, message: chat.chat) + } + + if previousCount != self.messages.count { + self.invalidateChat() + } + } else { + self.showDeleteSyncError() + } + }, + fail: { [unowned self] in + self.showDeleteSyncError() + } + ) + } + + func deleteChatsByUserId(userId: Int) { + guard isCreator else { + return + } + + agora.sendRawMessageToGroup( + rawMessage: LiveRoomChatRawMessage( + type: .DELETE_CHAT_BY_USER, + message: "", + can: 0, + donationMessage: nil, + targetUserId: userId + ), + completion: { [unowned self] _, error in + if error == nil { + let previousCount = self.messages.count + self.applyDeleteUserChats(userId: userId) + + if previousCount != self.messages.count { + self.invalidateChat() + } + } else { + self.showDeleteSyncError() + } + }, + fail: { [unowned self] in + self.showDeleteSyncError() + } + ) + } + + private func showDeleteSyncError() { + errorMessage = I18n.Common.commonError + isShowErrorPopup = true + } + + private func applyDeleteChat(chatId: String) { + messages.removeAll { chat in + guard chat.type == .CHAT, + let normalChat = chat as? LiveRoomNormalChat else { + return false + } + + return normalChat.chatId == chatId + } + } + + private func applyDeleteChatFallback(userId: Int, message: String) { + if let index = messages.firstIndex(where: { chat in + guard chat.type == .CHAT, + let normalChat = chat as? LiveRoomNormalChat else { + return false + } + + return normalChat.userId == userId && normalChat.chat == message + }) { + messages.remove(at: index) + } + } + + private func applyDeleteUserChats(userId: Int) { + messages.removeAll { chat in + switch chat.type { + case .CHAT: + guard let normalChat = chat as? LiveRoomNormalChat else { + return false + } + return normalChat.userId == userId + + case .DONATION: + guard let donationChat = chat as? LiveRoomDonationChat else { + return false + } + return donationChat.memberId == userId + + case .ROULETTE_DONATION: + guard let rouletteChat = chat as? LiveRoomRouletteDonationChat else { + return false + } + return rouletteChat.memberId == userId + + case .JOIN: + return false + } + } + } func donation(can: Int, message: String = "", isSecret: Bool = false) { if isSecret && can < 10 { @@ -1204,8 +1349,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject { } func kickOut() { - if UserDefaults.int(forKey: .userId) == liveRoomInfo?.creatorId { - repository.kickOut(roomId: AppState.shared.roomId, userId: kickOutId) + let targetUserId = kickOutId + + if UserDefaults.int(forKey: .userId) == liveRoomInfo?.creatorId, targetUserId > 0 { + repository.kickOut(roomId: AppState.shared.roomId, userId: targetUserId) .sink { result in switch result { case .finished: @@ -1213,19 +1360,37 @@ final class LiveRoomViewModel: NSObject, ObservableObject { case .failure(let error): ERROR_LOG(error.localizedDescription) } - } receiveValue: { _ in - + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + let nickname = self.getUserNicknameAndProfileUrl(accountId: targetUserId).nickname + self.agora.sendMessageToPeer(peerId: String(targetUserId), rawMessage: LiveRoomRequestType.KICK_OUT.rawValue.data(using: .utf8)!) { [unowned self] _, _ in + self.popupContent = "\(nickname)님을 내보냈습니다." + self.isShowPopup = true + } + self.deleteChatsByUserId(userId: targetUserId) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = I18n.Common.commonError + } + self.isShowErrorPopup = true + } + } catch { + self.errorMessage = I18n.Common.commonError + self.isShowErrorPopup = true + } } .store(in: &subscription) - - let nickname = getUserNicknameAndProfileUrl(accountId: kickOutId).nickname - agora.sendMessageToPeer(peerId: String(kickOutId), rawMessage: LiveRoomRequestType.KICK_OUT.rawValue.data(using: .utf8)!) { [unowned self] _, error in - self.popupContent = "\(nickname)님을 내보냈습니다." - self.isShowPopup = true - } } - - if let index = muteSpeakers.firstIndex(of: UInt(kickOutId)) { + + if let index = muteSpeakers.firstIndex(of: UInt(targetUserId)) { muteSpeakers.remove(at: index) } @@ -2075,6 +2240,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId)) self.messages.append( LiveRoomRouletteDonationChat( + memberId: UserDefaults.int(forKey: .userId), profileUrl: profileUrl, nickname: nickname, rouletteResult: rouletteSelectedItem @@ -2939,6 +3105,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate { } else if decoded.type == .ROULETTE_DONATION { self.messages.append( LiveRoomRouletteDonationChat( + memberId: Int(publisher)!, profileUrl: profileUrl, nickname: nickname, rouletteResult: decoded.message @@ -2958,6 +3125,47 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate { } else { DEBUG_LOG("Ignore TOGGLE_CHAT_FREEZE from non-creator publisher=\(publisher)") } + } else if decoded.type == .NORMAL_CHAT { + let memberId = Int(publisher) ?? 0 + let rank = self.getUserRank(userId: memberId) + + if let chatId = decoded.chatId, + !decoded.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !self.blockedMemberIdList.contains(memberId) { + self.messages.append( + LiveRoomNormalChat( + chatId: chatId, + userId: memberId, + profileUrl: profileUrl, + nickname: nickname, + rank: rank, + chat: decoded.message + ) + ) + } + } else if decoded.type == .DELETE_CHAT { + if Int(publisher) == self.liveRoomInfo?.creatorId { + if let chatId = decoded.chatId { + let previousCount = self.messages.count + self.applyDeleteChat(chatId: chatId) + + if previousCount == self.messages.count, + let targetUserId = decoded.targetUserId, + !decoded.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.applyDeleteChatFallback(userId: targetUserId, message: decoded.message) + } + } + } else { + DEBUG_LOG("Ignore DELETE_CHAT from non-creator publisher=\(publisher)") + } + } else if decoded.type == .DELETE_CHAT_BY_USER { + if Int(publisher) == self.liveRoomInfo?.creatorId { + if let targetUserId = decoded.targetUserId { + self.applyDeleteUserChats(userId: targetUserId) + } + } else { + DEBUG_LOG("Ignore DELETE_CHAT_BY_USER from non-creator publisher=\(publisher)") + } } else if decoded.type == .EDIT_ROOM_INFO || decoded.type == .SET_MANAGER { self.getRoomInfo() } else if decoded.type == .HEART_DONATION { @@ -2970,6 +3178,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate { self.addBigHeartAnimation() } } catch { + ERROR_LOG(error.localizedDescription) } } } @@ -2977,9 +3186,19 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate { if let message = textMessage { let memberId = Int(publisher) ?? 0 let rank = getUserRank(userId: memberId) - + let chatId = UUID().uuidString + if !message.trimmingCharacters(in: .whitespaces).isEmpty && !blockedMemberIdList.contains(memberId) { - messages.append(LiveRoomNormalChat(userId: memberId, profileUrl: profileUrl, nickname: nickname, rank: rank, chat: message)) + messages.append( + LiveRoomNormalChat( + chatId: chatId, + userId: memberId, + profileUrl: profileUrl, + nickname: nickname, + rank: rank, + chat: message + ) + ) } } diff --git a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift index 9b614b6..0c96706 100644 --- a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift +++ b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift @@ -10,7 +10,9 @@ import SwiftUI struct LiveRoomChatView: View { let messages: [LiveRoomChat] + let isCreator: Bool let getUserProfile: (Int) -> Void + let onLongPressChat: (LiveRoomNormalChat) -> Void var body: some View { LazyVStack(alignment: .leading, spacing: 18) { @@ -36,7 +38,8 @@ struct LiveRoomChatView: View { if chatMessage.userId != UserDefaults.int(forKey: .userId) { getUserProfile(chatMessage.userId) } - } + }, + onLongPressChat: isCreator ? { onLongPressChat(chatMessage) } : nil ) } } @@ -49,19 +52,23 @@ struct LiveRoomChatView_Previews: PreviewProvider { LiveRoomChatView( messages: [ LiveRoomRouletteDonationChat( + memberId: 0, profileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320", nickname: "jkljkljkl", rouletteResult: "sdfjkldfsjkl", type: .ROULETTE_DONATION ), LiveRoomRouletteDonationChat( + memberId: 1, profileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320", nickname: "jkljkljkl", rouletteResult: "sdfjkldfsjkl", type: .ROULETTE_DONATION ) ], - getUserProfile: { _ in } + isCreator: false, + getUserProfile: { _ in }, + onLongPressChat: { _ in } ) } } diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index ad34ef7..220e47f 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -30,6 +30,8 @@ struct LiveRoomViewV2: View { @State private var wavePhase: CGFloat = 0 @State private var isShowFollowNotifyDialog: Bool = false @State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil + @State private var selectedChatForDelete: LiveRoomNormalChat? = nil + @State private var isShowChatDeleteDialog: Bool = false let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect() private var appliedKeyboardHeight: CGFloat { @@ -213,11 +215,19 @@ struct LiveRoomViewV2: View { scrollObservableView if !viewModel.changeIsAdult || UserDefaults.bool(forKey: .auth) { - LiveRoomChatView(messages: viewModel.messages) { - if $0 != UserDefaults.int(forKey: .userId) { - viewModel.getUserProfile(userId: $0) + LiveRoomChatView( + messages: viewModel.messages, + isCreator: liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId), + getUserProfile: { + if $0 != UserDefaults.int(forKey: .userId) { + viewModel.getUserProfile(userId: $0) + } + }, + onLongPressChat: { chat in + selectedChatForDelete = chat + isShowChatDeleteDialog = true } - } + ) .frame(width: screenSize().width) .rotationEffect(Angle(degrees: 180)) .valueChanged(value: viewModel.messageChangeFlag) { _ in @@ -605,6 +615,24 @@ struct LiveRoomViewV2: View { } ) } + + if isShowChatDeleteDialog, let selectedChat = selectedChatForDelete { + SodaDialog( + title: I18n.LiveRoom.chatDeleteTitle, + desc: "\(selectedChat.nickname): \(selectedChat.chat)", + confirmButtonTitle: I18n.Common.delete, + confirmButtonAction: { + viewModel.deleteChat(selectedChat) + selectedChatForDelete = nil + isShowChatDeleteDialog = false + }, + cancelButtonTitle: I18n.Common.cancel, + cancelButtonAction: { + selectedChatForDelete = nil + isShowChatDeleteDialog = false + } + ) + } } ZStack { @@ -979,6 +1007,11 @@ struct LiveRoomViewV2: View { guestFollowButtonTypeOverride = nil } } + .onChange(of: isShowChatDeleteDialog) { isShowing in + if isShowing { + hideKeyboard() + } + } } private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat { diff --git a/docs/20260319_라이브룸채팅삭제기능구현계획.md b/docs/20260319_라이브룸채팅삭제기능구현계획.md new file mode 100644 index 0000000..c6cfddf --- /dev/null +++ b/docs/20260319_라이브룸채팅삭제기능구현계획.md @@ -0,0 +1,138 @@ +# 20260319_라이브룸채팅삭제기능구현계획.md + +## 개요 +- 라이브룸 V2 채팅에서 **방장(크리에이터)만** 특정 채팅을 삭제할 수 있는 기능을 추가한다. +- 삭제 대상 채팅을 길게 누르면 삭제 확인 알림창을 노출하고, 삭제 시 모든 참여자의 채팅 목록에서 해당 항목을 동시에 제거한다. +- 유저 강퇴 시에는 확인 알림창 없이 즉시 해당 유저의 채팅을 일괄 삭제하고, 동일하게 모든 참여자에게 동기화한다. +- 본 문서는 구현 전 상세 설계/영향 파일/검증 기준을 고정하기 위한 계획 문서다. + +## 요구사항 해석(고정) +- 채팅 삭제 권한: `liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId)` 인 경우만 허용. +- 롱프레스 대상: 일반 채팅(`LiveRoomNormalChat`) 버블. +- 삭제 확인 알림창 표시 포맷: `[닉네임]: [채팅 내용]`. +- 알림창 버튼: 취소 / 삭제. +- 삭제 전파 범위: 현재 룸의 모든 참여자 클라이언트. +- 강퇴 연계: 강퇴 확정 시 해당 유저 채팅 일괄 삭제를 즉시 실행(추가 확인 알림창 없음). + +## 설계 결정 +### 1) 삭제 전파 채널 +- 채팅 삭제 동기화는 RTM group raw message(`LiveRoomChatRawMessage`)로 전파한다. +- 강퇴는 기존처럼 peer `KICK_OUT`을 유지하되, 별도로 group 삭제 이벤트를 추가 전송한다. + +### 2) 단건 채팅 식별자 +- 단건 삭제 정확도를 위해 일반 채팅 모델에 `chatId`를 추가한다. +- 일반 채팅 송신은 텍스트 publish 경로 대신 raw message(`type = NORMAL_CHAT`)로 전환하여 `chatId`를 모든 클라이언트에 동일 전달한다. + +### 3) 강퇴 시 일괄 삭제 기준 +- `targetUserId` 기준으로 메시지 배열에서 작성자 매칭 항목을 제거한다. +- 기본 범위: 일반 채팅(`userId`) + 후원 채팅(`memberId`) + 룰렛 후원 채팅(작성자 식별 필드 추가 시 `memberId`). + +## 완료 기준 (Acceptance Criteria) +- [ ] AC1: 방장이 아닌 사용자는 채팅 롱프레스 시 삭제 액션이 노출되지 않는다. +- [ ] AC2: 방장이 일반 채팅 롱프레스 시 삭제 알림창이 노출되고, 본문이 `[닉네임]: [채팅 내용]` 포맷으로 표시된다. +- [ ] AC3: 삭제 알림창에서 `취소` 선택 시 채팅 목록 변경이 없다. +- [ ] AC4: 삭제 알림창에서 `삭제` 선택 시 해당 채팅 1건이 로컬에서 즉시 제거되고, 같은 룸 모든 사용자 화면에서도 제거된다. +- [ ] AC5: 강퇴 확정 시 대상 유저의 채팅이 확인 알림창 없이 즉시 일괄 제거된다. +- [ ] AC6: 강퇴 일괄 삭제도 같은 룸 모든 사용자 화면에 동기화된다. +- [ ] AC7: 기존 기능(채팅금지/채팅 얼림/스피커 초대/강퇴 팝업)은 회귀 없이 동작한다. + +## 구현 체크리스트 +### A. 채팅 모델/이벤트 확장 +- [x] `SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift` + - `LiveRoomNormalChat`에 `chatId` 필드 추가. + - 강퇴 일괄 삭제 범위를 위해 `LiveRoomRouletteDonationChat` 작성자 식별 필드(`memberId`) 추가 여부 확정 및 반영. +- [x] `SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift` +- `LiveRoomChatRawMessageType`에 `NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 추가. + - payload 필드(`chatId`, `targetUserId`)를 optional로 추가. + +### B. ViewModel 삭제 로직/동기화 +- [x] `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift` + - 일반 채팅 송신을 raw `NORMAL_CHAT` 이벤트 기반으로 전환하고 로컬 append 시 `chatId`를 유지. +- RTM 수신 분기에 `NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 처리 분기 추가. + - 방장 권한 가드가 포함된 `deleteChat(_:)`(단건) / `deleteChatsByUserId(_:)`(일괄) 메서드 추가. + - 강퇴 성공 경로(`kickOut()`)에 일괄 삭제 로컬 적용 + group 삭제 이벤트 브로드캐스트 추가. + - 메시지 제거 후 `invalidateChat()` 호출 일관화. + +### C. UI 롱프레스/삭제 확인 알림창 +- [x] `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift` + - 일반 채팅 항목 롱프레스 콜백 전달 구조 추가(`onLongPressChat`). + - 방장 여부 인자(`isCreator`)를 받아 롱프레스 활성 조건 반영. +- [x] `SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift` + - 채팅 버블 영역에 롱프레스 제스처 추가. + - 롱프레스 시 부모 콜백으로 `LiveRoomNormalChat` 전달. +- [x] `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` + - 삭제 대상 채팅 상태(`selectedChatForDelete`)와 삭제 알림창 표시 상태 추가. + - `LiveRoomChatView` 콜백 바인딩 및 방장 조건 연결. + - `SodaDialog`로 삭제 확인 UI 추가(취소/삭제, 본문 `[닉네임]: [채팅 내용]`). + - 삭제 확인 시 `viewModel.deleteChat(...)` 호출, 취소 시 상태 초기화. + +### D. 문구/국제화 +- [x] `SodaLive/Sources/I18n/I18n.swift` + - `I18n.LiveRoom`에 채팅 삭제 알림창 제목/실패 메시지(필요 시) 키 추가. + - 버튼 라벨은 기존 `I18n.Common.cancel`, `I18n.Common.delete` 재사용. + +## 영향 파일(예상) +### 필수 +- `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` +- `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift` +- `SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift` +- `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift` +- `SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift` +- `SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift` +- `SodaLive/Sources/I18n/I18n.swift` + +## 리스크 및 대응 +- 단건 삭제 식별자 미도입 시 동일 문구 중복 채팅 오삭제 위험이 있어 `chatId` 도입을 필수로 둔다. +- 일반 채팅 송신 경로를 raw로 전환하면 구버전 클라이언트 호환성 리스크가 있으므로 배포 시점 동기화가 필요하다. +- 강퇴와 삭제 이벤트 전파 순서가 뒤섞일 수 있으므로 강퇴 성공 콜백에서 삭제 이벤트를 먼저 브로드캐스트하고 UI 종료 흐름을 유지한다. + +## 검증 계획 +- 정적 진단: 수정 파일 `lsp_diagnostics` 확인. +- 빌드: + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 수동 QA 시나리오: + - 방장/일반유저 2계정 접속 후 일반유저 채팅 롱프레스 삭제 전파 확인. + - 일반유저 계정에서는 롱프레스 삭제 불가 확인. + - 동일 유저 강퇴 시 채팅 일괄 삭제 즉시 전파 확인(알림창 미노출). + +## 검증 기록 +- 2026-03-19 (계획 문서 초안) + - 무엇/왜/어떻게: 라이브룸 채팅 삭제 기능 구현 전, 현재 iOS 코드 경로(채팅 렌더링/RTM 수신/강퇴 처리)를 조사해 영향 파일과 구현 단계를 문서화했다. + - 실행 명령/도구: + - `read(LiveRoomViewV2.swift, LiveRoomViewModel.swift, LiveRoomChatView.swift, LiveRoomChatItemView.swift, LiveRoomChatRawMessage.swift, LiveApi.swift, LiveRepository.swift, I18n.swift 등)` + - `grep("KICK_OUT|sendMessage|didReceiveMessageEvent|LiveRoomChatRawMessageType|onLongPressGesture", include:"*.swift")` + - `glob("docs/*.md")` + - 결과: + - 문서 파일 생성 완료. + - 코드 구현/동작 변경은 아직 수행하지 않음. + +- 2026-03-19 (채팅 삭제 기능 구현) +- 무엇/왜/어떻게: 계획 문서 기준으로 방장 전용 롱프레스 채팅 삭제, 삭제 확인 다이얼로그(`[닉네임]: [채팅 내용]`), RTM 단건/일괄 삭제 전파(`NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER`), 강퇴 시 채팅 즉시 일괄 삭제를 구현했다. + - 실행 명령/도구: + - `lsp_diagnostics(LiveRoomChatRawMessage.swift, LiveRoomChat.swift, LiveRoomViewModel.swift, LiveRoomChatView.swift, LiveRoomChatItemView.swift, LiveRoomRouletteDonationChatItemView.swift, LiveRoomViewV2.swift, I18n.swift)` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- `grep("chatDeleteTitle|isShowChatDeleteDialog|deleteChat|NORMAL_CHAT|DELETE_CHAT|DELETE_CHAT_BY_USER|onLongPressChat", include:"*.swift")` + - 결과: + - `SodaLive`, `SodaLive-dev` Debug 빌드 모두 `** BUILD SUCCEEDED **` 확인. + - 두 스킴 모두 test action 미구성으로 자동 테스트 실행 불가(`Scheme ... is not currently configured for the test action`). + - `lsp_diagnostics`는 단일 파일 분석 한계로 일부 false positive(`No such module`, `scope`)가 있었으나, 실제 컴파일 유효성은 `xcodebuild` 성공으로 검증했다. + - CLI 환경 제약으로 2계정 실기기/시뮬레이터 상호작용 수동 QA는 후속 필요. + +- 2026-03-19 (Oracle 사후 점검 반영) + - 무엇/왜/어떻게: Oracle 리뷰에서 식별된 정합성 리스크(구버전 text 메시지 삭제 동기화, 삭제 브로드캐스트 실패 시 불일치, 공백 메시지 송수신 조건 불일치)를 보완했다. + - 실행 명령/도구: + - `task(subagent_type="oracle")` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + - 결과: + - `deleteChat(_:)`, `deleteChatsByUserId(_:)`에 RTM completion/fail 처리와 공통 오류 토스트 경로를 추가했다. + - `DELETE_CHAT` 수신 시 `chatId` 미일치 상황을 대비해 `(targetUserId + message)` fallback 삭제를 추가했다. + - `sendMessage`의 공백 메시지 판별을 송수신 동일 기준(`trimmingCharacters`)으로 맞췄다. + - 보강 후 `SodaLive`, `SodaLive-dev` Debug 빌드 재성공 확인. + - 테스트는 두 스킴 모두 test action 미구성으로 자동 실행 불가.