feat(live-room): 라이브룸 채팅 삭제 기능 구현

This commit is contained in:
Yu Sung
2026-03-20 10:51:22 +09:00
parent 793b5dd95a
commit 8eca5df62b
9 changed files with 433 additions and 27 deletions

View File

@@ -787,6 +787,7 @@ enum I18n {
static var chatFreezeOnStatusMessage: String { pick(ko: "채팅창을 얼렸습니다.", en: "Chat has been frozen.", ja: "チャットが凍結されました。") } 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 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 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 { enum LiveNow {

View File

@@ -16,6 +16,7 @@ protocol LiveRoomChat {
} }
struct LiveRoomNormalChat: LiveRoomChat { struct LiveRoomNormalChat: LiveRoomChat {
let chatId: String
let userId: Int let userId: Int
let profileUrl: String let profileUrl: String
let nickname: String let nickname: String
@@ -37,6 +38,7 @@ struct LiveRoomDonationChat: LiveRoomChat {
} }
struct LiveRoomRouletteDonationChat: LiveRoomChat { struct LiveRoomRouletteDonationChat: LiveRoomChat {
let memberId: Int
let profileUrl: String let profileUrl: String
let nickname: String let nickname: String
let rouletteResult: String let rouletteResult: String

View File

@@ -12,6 +12,7 @@ struct LiveRoomChatItemView: View {
let chatMessage: LiveRoomNormalChat let chatMessage: LiveRoomNormalChat
let onClickProfile: () -> Void let onClickProfile: () -> Void
let onLongPressChat: (() -> Void)?
private var rankValue: Int { private var rankValue: Int {
chatMessage.rank + 1 chatMessage.rank + 1
@@ -132,6 +133,9 @@ struct LiveRoomChatItemView: View {
Color.black.opacity(0.6) Color.black.opacity(0.6)
) )
.cornerRadius(3.3) .cornerRadius(3.3)
.onLongPressGesture {
onLongPressChat?()
}
} }
.frame(width: screenSize().width - 86, alignment: .leading) .frame(width: screenSize().width - 86, alignment: .leading)
.padding(.leading, 20) .padding(.leading, 20)

View File

@@ -10,7 +10,7 @@ import Foundation
struct LiveRoomChatRawMessage: Codable { struct LiveRoomChatRawMessage: Codable {
enum LiveRoomChatRawMessageType: String, Codable { enum LiveRoomChatRawMessageType: String, Codable {
case DONATION, SECRET_DONATION, EDIT_ROOM_INFO, SET_MANAGER, TOGGLE_ROULETTE, TOGGLE_CHAT_FREEZE, ROULETTE_DONATION 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 let type: LiveRoomChatRawMessageType
@@ -21,4 +21,6 @@ struct LiveRoomChatRawMessage: Codable {
let donationMessage: String? let donationMessage: String?
var isActiveRoulette: Bool? = nil var isActiveRoulette: Bool? = nil
var isChatFrozen: Bool? = nil var isChatFrozen: Bool? = nil
var chatId: String? = nil
var targetUserId: Int? = nil
} }

View File

@@ -66,6 +66,6 @@ struct LiveRoomRouletteDonationChatItemView: View {
struct LiveRoomRouletteDonationChatItemView_Previews: PreviewProvider { struct LiveRoomRouletteDonationChatItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
LiveRoomRouletteDonationChatItemView(chatMessage: LiveRoomRouletteDonationChat(profileUrl: "", nickname: "유저일", rouletteResult: "옵션1")) LiveRoomRouletteDonationChatItemView(chatMessage: LiveRoomRouletteDonationChat(memberId: 0, profileUrl: "", nickname: "유저일", rouletteResult: "옵션1"))
} }
} }

View File

@@ -318,6 +318,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
return isChatFrozen && liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) return isChatFrozen && liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId)
} }
private var isCreator: Bool {
liveRoomInfo?.creatorId == UserDefaults.int(forKey: .userId)
}
func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) { func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) {
guard isV2VJoined else { return } guard isV2VJoined else { return }
stopV2VTranslation(clearCaptionText: clearCaptionText) stopV2VTranslation(clearCaptionText: clearCaptionText)
@@ -695,12 +699,30 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} else if isNoChatting { } else if isNoChatting {
self.popupContent = "\(remainingNoChattingTime)초 동안 채팅하실 수 없습니다" self.popupContent = "\(remainingNoChattingTime)초 동안 채팅하실 수 없습니다"
self.isShowPopup = true self.isShowPopup = true
} else if chatMessage.count > 0 { } else if !chatMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
agora.sendMessageToGroup(textMessage: chatMessage) { _, error in 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 { if error == nil {
let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId)) let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId))
let rank = self.getUserRank(userId: 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() self.invalidateChat()
} }
@@ -711,6 +733,129 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
} }
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) { func donation(can: Int, message: String = "", isSecret: Bool = false) {
if isSecret && can < 10 { if isSecret && can < 10 {
popupContent = "비밀 미션은 최소 10캔 이상부터 이용이 가능합니다." popupContent = "비밀 미션은 최소 10캔 이상부터 이용이 가능합니다."
@@ -1204,8 +1349,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
func kickOut() { func kickOut() {
if UserDefaults.int(forKey: .userId) == liveRoomInfo?.creatorId { let targetUserId = kickOutId
repository.kickOut(roomId: AppState.shared.roomId, userId: kickOutId)
if UserDefaults.int(forKey: .userId) == liveRoomInfo?.creatorId, targetUserId > 0 {
repository.kickOut(roomId: AppState.shared.roomId, userId: targetUserId)
.sink { result in .sink { result in
switch result { switch result {
case .finished: case .finished:
@@ -1213,19 +1360,37 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
case .failure(let error): case .failure(let error):
ERROR_LOG(error.localizedDescription) 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) .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) muteSpeakers.remove(at: index)
} }
@@ -2075,6 +2240,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId)) let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId))
self.messages.append( self.messages.append(
LiveRoomRouletteDonationChat( LiveRoomRouletteDonationChat(
memberId: UserDefaults.int(forKey: .userId),
profileUrl: profileUrl, profileUrl: profileUrl,
nickname: nickname, nickname: nickname,
rouletteResult: rouletteSelectedItem rouletteResult: rouletteSelectedItem
@@ -2939,6 +3105,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
} else if decoded.type == .ROULETTE_DONATION { } else if decoded.type == .ROULETTE_DONATION {
self.messages.append( self.messages.append(
LiveRoomRouletteDonationChat( LiveRoomRouletteDonationChat(
memberId: Int(publisher)!,
profileUrl: profileUrl, profileUrl: profileUrl,
nickname: nickname, nickname: nickname,
rouletteResult: decoded.message rouletteResult: decoded.message
@@ -2958,6 +3125,47 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
} else { } else {
DEBUG_LOG("Ignore TOGGLE_CHAT_FREEZE from non-creator publisher=\(publisher)") 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 { } else if decoded.type == .EDIT_ROOM_INFO || decoded.type == .SET_MANAGER {
self.getRoomInfo() self.getRoomInfo()
} else if decoded.type == .HEART_DONATION { } else if decoded.type == .HEART_DONATION {
@@ -2970,6 +3178,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
self.addBigHeartAnimation() self.addBigHeartAnimation()
} }
} catch { } catch {
ERROR_LOG(error.localizedDescription)
} }
} }
} }
@@ -2977,9 +3186,19 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
if let message = textMessage { if let message = textMessage {
let memberId = Int(publisher) ?? 0 let memberId = Int(publisher) ?? 0
let rank = getUserRank(userId: memberId) let rank = getUserRank(userId: memberId)
let chatId = UUID().uuidString
if !message.trimmingCharacters(in: .whitespaces).isEmpty && !blockedMemberIdList.contains(memberId) { 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
)
)
} }
} }

View File

@@ -10,7 +10,9 @@ import SwiftUI
struct LiveRoomChatView: View { struct LiveRoomChatView: View {
let messages: [LiveRoomChat] let messages: [LiveRoomChat]
let isCreator: Bool
let getUserProfile: (Int) -> Void let getUserProfile: (Int) -> Void
let onLongPressChat: (LiveRoomNormalChat) -> Void
var body: some View { var body: some View {
LazyVStack(alignment: .leading, spacing: 18) { LazyVStack(alignment: .leading, spacing: 18) {
@@ -36,7 +38,8 @@ struct LiveRoomChatView: View {
if chatMessage.userId != UserDefaults.int(forKey: .userId) { if chatMessage.userId != UserDefaults.int(forKey: .userId) {
getUserProfile(chatMessage.userId) getUserProfile(chatMessage.userId)
} }
} },
onLongPressChat: isCreator ? { onLongPressChat(chatMessage) } : nil
) )
} }
} }
@@ -49,19 +52,23 @@ struct LiveRoomChatView_Previews: PreviewProvider {
LiveRoomChatView( LiveRoomChatView(
messages: [ messages: [
LiveRoomRouletteDonationChat( LiveRoomRouletteDonationChat(
memberId: 0,
profileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320", profileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320",
nickname: "jkljkljkl", nickname: "jkljkljkl",
rouletteResult: "sdfjkldfsjkl", rouletteResult: "sdfjkldfsjkl",
type: .ROULETTE_DONATION type: .ROULETTE_DONATION
), ),
LiveRoomRouletteDonationChat( LiveRoomRouletteDonationChat(
memberId: 1,
profileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320", profileUrl: "https://cf.sodalive.net/profile/26/26-profile-ddf78b4d-0300-4c50-9c84-5d8a95fd5fe2-4892-1705256364320",
nickname: "jkljkljkl", nickname: "jkljkljkl",
rouletteResult: "sdfjkldfsjkl", rouletteResult: "sdfjkldfsjkl",
type: .ROULETTE_DONATION type: .ROULETTE_DONATION
) )
], ],
getUserProfile: { _ in } isCreator: false,
getUserProfile: { _ in },
onLongPressChat: { _ in }
) )
} }
} }

View File

@@ -30,6 +30,8 @@ struct LiveRoomViewV2: View {
@State private var wavePhase: CGFloat = 0 @State private var wavePhase: CGFloat = 0
@State private var isShowFollowNotifyDialog: Bool = false @State private var isShowFollowNotifyDialog: Bool = false
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil @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() let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
private var appliedKeyboardHeight: CGFloat { private var appliedKeyboardHeight: CGFloat {
@@ -213,11 +215,19 @@ struct LiveRoomViewV2: View {
scrollObservableView scrollObservableView
if !viewModel.changeIsAdult || UserDefaults.bool(forKey: .auth) { if !viewModel.changeIsAdult || UserDefaults.bool(forKey: .auth) {
LiveRoomChatView(messages: viewModel.messages) { LiveRoomChatView(
if $0 != UserDefaults.int(forKey: .userId) { messages: viewModel.messages,
viewModel.getUserProfile(userId: $0) 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) .frame(width: screenSize().width)
.rotationEffect(Angle(degrees: 180)) .rotationEffect(Angle(degrees: 180))
.valueChanged(value: viewModel.messageChangeFlag) { _ in .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 { ZStack {
@@ -979,6 +1007,11 @@ struct LiveRoomViewV2: View {
guestFollowButtonTypeOverride = nil guestFollowButtonTypeOverride = nil
} }
} }
.onChange(of: isShowChatDeleteDialog) { isShowing in
if isShowing {
hideKeyboard()
}
}
} }
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat { private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {

View File

@@ -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 미구성으로 자동 실행 불가.