Compare commits
6 Commits
0a22f87acc
...
4f66ffb595
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f66ffb595 | ||
|
|
91b5ed974f | ||
|
|
af31444f0f | ||
|
|
8eca5df62b | ||
|
|
793b5dd95a | ||
|
|
70003af82b |
21
SodaLive/Resources/Assets.xcassets/ic_ice.imageset/Contents.json
vendored
Normal file
21
SodaLive/Resources/Assets.xcassets/ic_ice.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ic_ice.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_ice.imageset/ic_ice.png
vendored
Normal file
BIN
SodaLive/Resources/Assets.xcassets/ic_ice.imageset/ic_ice.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -22,6 +22,10 @@ struct ChatTextFieldView: UIViewRepresentable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
|
||||||
|
return parent.isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
@objc func textDidChange(_ textField: UITextField) {
|
@objc func textDidChange(_ textField: UITextField) {
|
||||||
parent.text = textField.text ?? ""
|
parent.text = textField.text ?? ""
|
||||||
}
|
}
|
||||||
@@ -29,6 +33,7 @@ struct ChatTextFieldView: UIViewRepresentable {
|
|||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
var placeholder: String
|
var placeholder: String
|
||||||
|
var isEnabled: Bool = true
|
||||||
var onSend: () -> Void
|
var onSend: () -> Void
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextField {
|
func makeUIView(context: Context) -> UITextField {
|
||||||
@@ -44,6 +49,7 @@ struct ChatTextFieldView: UIViewRepresentable {
|
|||||||
textField.tintColor = UIColor(hex: "3BB9F1")
|
textField.tintColor = UIColor(hex: "3BB9F1")
|
||||||
textField.font = UIFont(name: Font.preMedium.rawValue, size: 13.3)
|
textField.font = UIFont(name: Font.preMedium.rawValue, size: 13.3)
|
||||||
textField.returnKeyType = .send
|
textField.returnKeyType = .send
|
||||||
|
textField.isEnabled = isEnabled
|
||||||
textField.setContentHuggingPriority(.defaultLow, for: .horizontal) // 우선순위 낮추기
|
textField.setContentHuggingPriority(.defaultLow, for: .horizontal) // 우선순위 낮추기
|
||||||
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) // 우선순위 낮추기
|
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) // 우선순위 낮추기
|
||||||
textField.addTarget(context.coordinator, action: #selector(Coordinator.textDidChange(_:)), for: .editingChanged)
|
textField.addTarget(context.coordinator, action: #selector(Coordinator.textDidChange(_:)), for: .editingChanged)
|
||||||
@@ -51,7 +57,13 @@ struct ChatTextFieldView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UITextField, context: Context) {
|
func updateUIView(_ uiView: UITextField, context: Context) {
|
||||||
|
context.coordinator.parent = self
|
||||||
uiView.text = text
|
uiView.text = text
|
||||||
|
uiView.isEnabled = isEnabled
|
||||||
|
|
||||||
|
if !isEnabled && uiView.isFirstResponder {
|
||||||
|
uiView.resignFirstResponder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
|
|||||||
@@ -773,6 +773,8 @@ enum I18n {
|
|||||||
|
|
||||||
static var signatureOn: String { pick(ko: "시그 ON", en: "Sign ON", ja: "シグ ON") }
|
static var signatureOn: String { pick(ko: "시그 ON", en: "Sign ON", ja: "シグ ON") }
|
||||||
static var signatureOff: String { pick(ko: "시그 OFF", en: "Sign OFF", ja: "シグ OFF") }
|
static var signatureOff: String { pick(ko: "시그 OFF", en: "Sign OFF", ja: "シグ OFF") }
|
||||||
|
static var chatFreezeOn: String { pick(ko: "얼림 ON", en: "Freeze ON", ja: "凍結 ON") }
|
||||||
|
static var chatFreezeOff: String { pick(ko: "얼림 OFF", en: "Freeze OFF", ja: "凍結 OFF") }
|
||||||
static var captionOn: String { pick(ko: "자막 ON", en: "Caption ON", ja: "字幕 ON") }
|
static var captionOn: String { pick(ko: "자막 ON", en: "Caption ON", ja: "字幕 ON") }
|
||||||
static var captionOff: String { pick(ko: "자막 OFF", en: "Caption OFF", ja: "字幕 OFF") }
|
static var captionOff: String { pick(ko: "자막 OFF", en: "Caption OFF", ja: "字幕 OFF") }
|
||||||
static var backgroundOn: String { pick(ko: "배경 ON", en: "Back ON", ja: "背景 ON") }
|
static var backgroundOn: String { pick(ko: "배경 ON", en: "Back ON", ja: "背景 ON") }
|
||||||
@@ -782,6 +784,11 @@ enum I18n {
|
|||||||
static var participants: String { pick(ko: "참여자", en: "Participants", ja: "リスナー") }
|
static var participants: String { pick(ko: "참여자", en: "Participants", ja: "リスナー") }
|
||||||
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
|
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
|
||||||
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
||||||
|
static var chatFreezeOnStatusMessageForCreator: String { pick(ko: "“🧊 모두들 얼음!” 채팅창을 얼렸습니다.", en: "\"🧊 Freeze, everyone!\" The chat has been frozen.", ja: "「🧊 みんなフリーズ!」チャットを凍結しました。") }
|
||||||
|
static var chatFreezeOnStatusMessageForListener: String { pick(ko: "“🧊 모두들 얼음!” 채팅창이 얼었습니다.", en: "\"🧊 Freeze, everyone!\" The chat is now frozen.", ja: "「🧊 みんなフリーズ!」チャットが凍結されました。") }
|
||||||
|
static var chatFreezeOffStatusMessage: String { pick(ko: "“💧땡! “ 채팅창 얼리기가 해제되었습니다.", en: "\"💧 Ding!\" Chat freeze has been lifted.", ja: "「💧 たん!」チャット凍結が解除されました。") }
|
||||||
|
static var chatFreezeBlockedMessage: String { pick(ko: "🧊 채팅창이 얼었습니다.", en: "🧊 The chat is now frozen.", ja: "🧊 チャットが凍結されました。") }
|
||||||
|
static var chatDeleteTitle: String { pick(ko: "채팅 삭제", en: "Delete chat", ja: "チャット削除") }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LiveNow {
|
enum LiveNow {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ enum LiveApi {
|
|||||||
case setListener(request: SetManagerOrSpeakerOrAudienceRequest)
|
case setListener(request: SetManagerOrSpeakerOrAudienceRequest)
|
||||||
case setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest)
|
case setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest)
|
||||||
case setManager(request: SetManagerOrSpeakerOrAudienceRequest)
|
case setManager(request: SetManagerOrSpeakerOrAudienceRequest)
|
||||||
|
case setChatFreeze(request: SetChatFreezeRequest)
|
||||||
case kickOut(request: LiveRoomKickOutRequest)
|
case kickOut(request: LiveRoomKickOutRequest)
|
||||||
case donationStatus(roomId: Int)
|
case donationStatus(roomId: Int)
|
||||||
case donationTotal(roomId: Int)
|
case donationTotal(roomId: Int)
|
||||||
@@ -112,6 +113,9 @@ extension LiveApi: TargetType {
|
|||||||
|
|
||||||
case .setManager:
|
case .setManager:
|
||||||
return "/live/room/info/set/manager"
|
return "/live/room/info/set/manager"
|
||||||
|
|
||||||
|
case .setChatFreeze:
|
||||||
|
return "/live/room/info/set/chat-freeze"
|
||||||
|
|
||||||
case .kickOut:
|
case .kickOut:
|
||||||
return "/live/room/kick-out"
|
return "/live/room/kick-out"
|
||||||
@@ -156,7 +160,7 @@ extension LiveApi: TargetType {
|
|||||||
case .makeReservation, .enterRoom, .createRoom, .quitRoom, .donation, .refundDonation, .kickOut, .likeHeart:
|
case .makeReservation, .enterRoom, .createRoom, .quitRoom, .donation, .refundDonation, .kickOut, .likeHeart:
|
||||||
return .post
|
return .post
|
||||||
|
|
||||||
case .setListener, .setSpeaker, .setManager, .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo:
|
case .setListener, .setSpeaker, .setManager, .setChatFreeze, .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo:
|
||||||
return .put
|
return .put
|
||||||
|
|
||||||
case .deleteDonationMessage:
|
case .deleteDonationMessage:
|
||||||
@@ -237,6 +241,9 @@ extension LiveApi: TargetType {
|
|||||||
|
|
||||||
case .setListener(let request), .setSpeaker(let request), .setManager(let request):
|
case .setListener(let request), .setSpeaker(let request), .setManager(let request):
|
||||||
return .requestJSONEncodable(request)
|
return .requestJSONEncodable(request)
|
||||||
|
|
||||||
|
case .setChatFreeze(let request):
|
||||||
|
return .requestJSONEncodable(request)
|
||||||
|
|
||||||
case .kickOut(let request):
|
case .kickOut(let request):
|
||||||
return .requestJSONEncodable(request)
|
return .requestJSONEncodable(request)
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ final class LiveRepository {
|
|||||||
func setManager(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> {
|
func setManager(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
return api.requestPublisher(.setManager(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId)))
|
return api.requestPublisher(.setManager(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setChatFreeze(roomId: Int, isChatFrozen: Bool) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.setChatFreeze(request: SetChatFreezeRequest(roomId: roomId, isChatFrozen: isChatFrozen)))
|
||||||
|
}
|
||||||
|
|
||||||
func kickOut(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> {
|
func kickOut(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
return api.requestPublisher(.kickOut(request: LiveRoomKickOutRequest(roomId: roomId, userId: userId)))
|
return api.requestPublisher(.kickOut(request: LiveRoomKickOutRequest(roomId: roomId, userId: userId)))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -46,6 +48,12 @@ struct LiveRoomRouletteDonationChat: LiveRoomChat {
|
|||||||
|
|
||||||
struct LiveRoomJoinChat: LiveRoomChat {
|
struct LiveRoomJoinChat: LiveRoomChat {
|
||||||
let nickname: String
|
let nickname: String
|
||||||
|
let statusMessage: String?
|
||||||
|
|
||||||
var type: LiveRoomChatType = .JOIN
|
var type: LiveRoomChatType = .JOIN
|
||||||
|
|
||||||
|
init(nickname: String, statusMessage: String? = nil) {
|
||||||
|
self.nickname = nickname
|
||||||
|
self.statusMessage = statusMessage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ 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, 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
|
||||||
@@ -20,4 +20,7 @@ struct LiveRoomChatRawMessage: Codable {
|
|||||||
var signatureImageUrl: String? = nil
|
var signatureImageUrl: String? = nil
|
||||||
let donationMessage: String?
|
let donationMessage: String?
|
||||||
var isActiveRoulette: Bool? = nil
|
var isActiveRoulette: Bool? = nil
|
||||||
|
var isChatFrozen: Bool? = nil
|
||||||
|
var chatId: String? = nil
|
||||||
|
var targetUserId: Int? = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,18 +12,27 @@ struct LiveRoomJoinChatItemView: View {
|
|||||||
let chatMessage: LiveRoomJoinChat
|
let chatMessage: LiveRoomJoinChat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
Group {
|
||||||
Text("'")
|
if let statusMessage = chatMessage.statusMessage,
|
||||||
.appFont(size: 12)
|
!statusMessage.isEmpty {
|
||||||
.foregroundColor(Color.grayee)
|
Text(statusMessage)
|
||||||
|
.appFont(size: 12)
|
||||||
Text(chatMessage.nickname)
|
.foregroundColor(Color.grayee)
|
||||||
.appFont(size: 12, weight: .bold)
|
} else {
|
||||||
.foregroundColor(Color.mainYellow)
|
HStack(spacing: 0) {
|
||||||
|
Text("'")
|
||||||
Text("'님이 입장하셨습니다.")
|
.appFont(size: 12)
|
||||||
.appFont(size: 12)
|
.foregroundColor(Color.grayee)
|
||||||
.foregroundColor(Color.grayee)
|
|
||||||
|
Text(chatMessage.nickname)
|
||||||
|
.appFont(size: 12, weight: .bold)
|
||||||
|
.foregroundColor(Color.mainYellow)
|
||||||
|
|
||||||
|
Text("'님이 입장하셨습니다.")
|
||||||
|
.appFont(size: 12)
|
||||||
|
.foregroundColor(Color.grayee)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6.7)
|
.padding(.vertical, 6.7)
|
||||||
.frame(width: screenSize().width - 86)
|
.frame(width: screenSize().width - 86)
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct GetRoomInfoResponse: Decodable {
|
|||||||
let menuPan: String
|
let menuPan: String
|
||||||
let creatorLanguageCode: String?
|
let creatorLanguageCode: String?
|
||||||
let isActiveRoulette: Bool
|
let isActiveRoulette: Bool
|
||||||
|
let isChatFrozen: Bool?
|
||||||
let isPrivateRoom: Bool
|
let isPrivateRoom: Bool
|
||||||
let password: String?
|
let password: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
@Published var remainingNoChattingTime = 0
|
@Published var remainingNoChattingTime = 0
|
||||||
|
|
||||||
@Published var isActiveRoulette = false
|
@Published var isActiveRoulette = false
|
||||||
|
@Published var isChatFrozen = false
|
||||||
|
|
||||||
@Published var isShowRouletteSettings = false
|
@Published var isShowRouletteSettings = false
|
||||||
|
|
||||||
@@ -281,6 +282,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
var bigHeartParticleTimer: DispatchSourceTimer?
|
var bigHeartParticleTimer: DispatchSourceTimer?
|
||||||
|
|
||||||
var isAvailableLikeHeart = false
|
var isAvailableLikeHeart = false
|
||||||
|
private var isSettingChatFreeze = false
|
||||||
|
|
||||||
private var blockedMemberIdList = Set<Int>()
|
private var blockedMemberIdList = Set<Int>()
|
||||||
|
|
||||||
@@ -308,6 +310,18 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
v2vAgentId != nil
|
v2vAgentId != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isChatFrozenForCurrentUser: Bool {
|
||||||
|
guard let liveRoomInfo = liveRoomInfo else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -591,6 +605,9 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
let decoded = try jsonDecoder.decode(ApiResponse<GetRoomInfoResponse>.self, from: responseData)
|
let decoded = try jsonDecoder.decode(ApiResponse<GetRoomInfoResponse>.self, from: responseData)
|
||||||
|
|
||||||
if let data = decoded.data, decoded.success {
|
if let data = decoded.data, decoded.success {
|
||||||
|
let previousIsChatFrozen = self.isChatFrozen
|
||||||
|
let syncedIsChatFrozen = data.isChatFrozen ?? false
|
||||||
|
|
||||||
self.liveRoomInfo = data
|
self.liveRoomInfo = data
|
||||||
self.updateV2VAvailability(roomInfo: data)
|
self.updateV2VAvailability(roomInfo: data)
|
||||||
|
|
||||||
@@ -599,6 +616,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.isActiveRoulette = data.isActiveRoulette
|
self.isActiveRoulette = data.isActiveRoulette
|
||||||
|
self.isChatFrozen = syncedIsChatFrozen
|
||||||
|
|
||||||
|
if syncedIsChatFrozen && !previousIsChatFrozen {
|
||||||
|
self.appendChatFreezeStatusMessage(isChatFrozen: true)
|
||||||
|
}
|
||||||
|
|
||||||
self.isLoading = true
|
self.isLoading = true
|
||||||
|
|
||||||
let rtcState = self.agora.getRtcConnectionState()
|
let rtcState = self.agora.getRtcConnectionState()
|
||||||
@@ -670,24 +693,168 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
|
|
||||||
func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) {
|
func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) {
|
||||||
DispatchQueue.main.async {[unowned self] in
|
DispatchQueue.main.async {[unowned self] in
|
||||||
if isNoChatting {
|
if isChatFrozenForCurrentUser {
|
||||||
|
self.popupContent = I18n.LiveRoom.chatFreezeBlockedMessage
|
||||||
|
self.isShowPopup = true
|
||||||
|
} 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess()
|
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) {
|
func donation(can: Int, message: String = "", isSecret: Bool = false) {
|
||||||
if isSecret && can < 10 {
|
if isSecret && can < 10 {
|
||||||
@@ -1182,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:
|
||||||
@@ -1191,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1883,6 +2070,81 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setChatFreeze(isChatFrozen: Bool) {
|
||||||
|
guard let liveRoomInfo = liveRoomInfo,
|
||||||
|
liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId),
|
||||||
|
!isSettingChatFreeze else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSettingChatFreeze = true
|
||||||
|
|
||||||
|
repository.setChatFreeze(roomId: liveRoomInfo.roomId, isChatFrozen: isChatFrozen)
|
||||||
|
.sink { [unowned self] result in
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.isSettingChatFreeze = false
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowErrorPopup = true
|
||||||
|
}
|
||||||
|
} receiveValue: { [unowned self] response in
|
||||||
|
self.isSettingChatFreeze = false
|
||||||
|
let responseData = response.data
|
||||||
|
|
||||||
|
do {
|
||||||
|
let jsonDecoder = JSONDecoder()
|
||||||
|
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
|
||||||
|
|
||||||
|
if decoded.success {
|
||||||
|
self.isChatFrozen = isChatFrozen
|
||||||
|
self.appendChatFreezeStatusMessage(isChatFrozen: isChatFrozen)
|
||||||
|
self.invalidateChat()
|
||||||
|
|
||||||
|
self.agora.sendRawMessageToGroup(
|
||||||
|
rawMessage: LiveRoomChatRawMessage(
|
||||||
|
type: .TOGGLE_CHAT_FREEZE,
|
||||||
|
message: "",
|
||||||
|
can: 0,
|
||||||
|
donationMessage: "",
|
||||||
|
isActiveRoulette: nil,
|
||||||
|
isChatFrozen: isChatFrozen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendChatFreezeStatusMessage(isChatFrozen: Bool) {
|
||||||
|
let statusMessage: String
|
||||||
|
|
||||||
|
if isChatFrozen {
|
||||||
|
statusMessage = isCreator
|
||||||
|
? I18n.LiveRoom.chatFreezeOnStatusMessageForCreator
|
||||||
|
: I18n.LiveRoom.chatFreezeOnStatusMessageForListener
|
||||||
|
} else {
|
||||||
|
statusMessage = I18n.LiveRoom.chatFreezeOffStatusMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.append(LiveRoomJoinChat(nickname: "", statusMessage: statusMessage))
|
||||||
|
}
|
||||||
|
|
||||||
func showRoulette() {
|
func showRoulette() {
|
||||||
if let liveRoomInfo = liveRoomInfo, !isLoading {
|
if let liveRoomInfo = liveRoomInfo, !isLoading {
|
||||||
@@ -1985,6 +2247,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
|
||||||
@@ -2849,6 +3112,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
|
||||||
@@ -2858,6 +3122,57 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
|
|||||||
self.totalDonationCan += decoded.can
|
self.totalDonationCan += decoded.can
|
||||||
} else if decoded.type == .TOGGLE_ROULETTE && decoded.isActiveRoulette != nil {
|
} else if decoded.type == .TOGGLE_ROULETTE && decoded.isActiveRoulette != nil {
|
||||||
self.isActiveRoulette = decoded.isActiveRoulette!
|
self.isActiveRoulette = decoded.isActiveRoulette!
|
||||||
|
} else if decoded.type == .TOGGLE_CHAT_FREEZE && decoded.isChatFrozen != nil {
|
||||||
|
if Int(publisher) == self.liveRoomInfo?.creatorId {
|
||||||
|
self.isChatFrozen = decoded.isChatFrozen!
|
||||||
|
|
||||||
|
if Int(publisher) != UserDefaults.int(forKey: .userId) {
|
||||||
|
self.appendChatFreezeStatusMessage(isChatFrozen: self.isChatFrozen)
|
||||||
|
}
|
||||||
|
} 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 {
|
} 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 {
|
||||||
@@ -2870,6 +3185,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
|
|||||||
self.addBigHeartAnimation()
|
self.addBigHeartAnimation()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2877,9 +3193,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
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,8 @@ struct SetManagerOrSpeakerOrAudienceRequest: Encodable {
|
|||||||
let roomId: Int
|
let roomId: Int
|
||||||
let memberId: Int
|
let memberId: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SetChatFreezeRequest: Encodable {
|
||||||
|
let roomId: Int
|
||||||
|
let isChatFrozen: Bool
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ struct LiveRoomRightBottomButton: View {
|
|||||||
|
|
||||||
let imageName: String
|
let imageName: String
|
||||||
let onClick: () -> Void
|
let onClick: () -> Void
|
||||||
|
let backgroundColor: Color?
|
||||||
let onLongPress: (() -> Void)?
|
let onLongPress: (() -> Void)?
|
||||||
let longPressDuration: Double
|
let longPressDuration: Double
|
||||||
|
|
||||||
init(
|
init(
|
||||||
imageName: String,
|
imageName: String,
|
||||||
onClick: @escaping () -> Void,
|
onClick: @escaping () -> Void,
|
||||||
|
backgroundColor: Color? = nil,
|
||||||
onLongPress: (() -> Void)? = nil,
|
onLongPress: (() -> Void)? = nil,
|
||||||
longPressDuration: Double = 2.0
|
longPressDuration: Double = 2.0
|
||||||
) {
|
) {
|
||||||
self.imageName = imageName
|
self.imageName = imageName
|
||||||
self.onClick = onClick
|
self.onClick = onClick
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
self.onLongPress = onLongPress
|
self.onLongPress = onLongPress
|
||||||
self.longPressDuration = longPressDuration
|
self.longPressDuration = longPressDuration
|
||||||
}
|
}
|
||||||
@@ -31,7 +34,15 @@ struct LiveRoomRightBottomButton: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.background(Color.gray52.opacity(0.6))
|
.background(
|
||||||
|
backgroundColor ?? Color(
|
||||||
|
.sRGB,
|
||||||
|
red: 82 / 255,
|
||||||
|
green: 82 / 255,
|
||||||
|
blue: 82 / 255,
|
||||||
|
opacity: 0.6
|
||||||
|
)
|
||||||
|
)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.onTapGesture { onClick() }
|
.onTapGesture { onClick() }
|
||||||
.onLongPressGesture(minimumDuration: longPressDuration) {
|
.onLongPressGesture(minimumDuration: longPressDuration) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Kingfisher
|
|
||||||
|
|
||||||
struct LiveRoomInfoHostView: View {
|
struct LiveRoomInfoHostView: View {
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ struct LiveRoomInfoHostView: View {
|
|||||||
) { onClickQuit() }
|
) { onClickQuit() }
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
LiveRoomOverlayStrokeTextToggleButton(
|
LiveRoomOverlayStrokeTextToggleButton(
|
||||||
isOn: isOnSignature,
|
isOn: isOnSignature,
|
||||||
onText: I18n.LiveRoom.signatureOn,
|
onText: I18n.LiveRoom.signatureOn,
|
||||||
|
|||||||
@@ -11,16 +11,27 @@ struct LiveRoomInputChatView: View {
|
|||||||
|
|
||||||
@State private var chatMessage = ""
|
@State private var chatMessage = ""
|
||||||
|
|
||||||
|
let isInputDisabled: Bool
|
||||||
let sendMessage: (String) -> Bool
|
let sendMessage: (String) -> Bool
|
||||||
|
let onDisabledInputTap: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6.7) {
|
HStack(spacing: 6.7) {
|
||||||
ChatTextFieldView(text: $chatMessage, placeholder: "채팅을 입력하세요") {
|
ChatTextFieldView(text: $chatMessage, placeholder: "채팅을 입력하세요", isEnabled: !isInputDisabled) {
|
||||||
if sendMessage(chatMessage) {
|
if sendMessage(chatMessage) {
|
||||||
chatMessage = ""
|
chatMessage = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.allowsHitTesting(!isInputDisabled)
|
||||||
|
.overlay {
|
||||||
|
if isInputDisabled {
|
||||||
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
onDisabledInputTap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(.vertical, 18.3)
|
.padding(.vertical, 18.3)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -28,7 +39,13 @@ struct LiveRoomInputChatView: View {
|
|||||||
Image("btn_message_send")
|
Image("btn_message_send")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 35, height: 35)
|
.frame(width: 35, height: 35)
|
||||||
|
.opacity(isInputDisabled ? 0.5 : 1)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
if isInputDisabled {
|
||||||
|
onDisabledInputTap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if sendMessage(chatMessage) {
|
if sendMessage(chatMessage) {
|
||||||
chatMessage = ""
|
chatMessage = ""
|
||||||
}
|
}
|
||||||
@@ -43,12 +60,18 @@ struct LiveRoomInputChatView: View {
|
|||||||
.strokeBorder(lineWidth: 1)
|
.strokeBorder(lineWidth: 1)
|
||||||
.foregroundColor(.gray77)
|
.foregroundColor(.gray77)
|
||||||
)
|
)
|
||||||
|
.onChange(of: isInputDisabled) { isDisabled in
|
||||||
|
if isDisabled {
|
||||||
|
hideKeyboard()
|
||||||
|
chatMessage = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(13.3)
|
.padding(13.3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LiveRoomInputChatView_Previews: PreviewProvider {
|
struct LiveRoomInputChatView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
LiveRoomInputChatView(sendMessage: { _ in return true })
|
LiveRoomInputChatView(isInputDisabled: false, sendMessage: { _ in return true }, onDisabledInputTap: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,33 @@ 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 {
|
||||||
|
guard !viewModel.isChatFrozenForCurrentUser else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(keyboardHandler.keyboardHeight, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isChatInputDisabled: Bool {
|
||||||
|
viewModel.isChatFrozenForCurrentUser || viewModel.isNoChatting
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chatInputBlockedMessage: String {
|
||||||
|
if viewModel.isChatFrozenForCurrentUser {
|
||||||
|
return I18n.LiveRoom.chatFreezeBlockedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isNoChatting {
|
||||||
|
return "\(viewModel.remainingNoChattingTime)초 동안 채팅하실 수 없습니다"
|
||||||
|
}
|
||||||
|
|
||||||
|
return I18n.LiveRoom.chatFreezeBlockedMessage
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -185,11 +211,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
|
||||||
@@ -210,11 +244,13 @@ struct LiveRoomViewV2: View {
|
|||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 0) {
|
VStack(alignment: .trailing, spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
LiveRoomRightBottomButton(
|
VStack(spacing: 13.3) {
|
||||||
imageName: viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on",
|
LiveRoomRightBottomButton(
|
||||||
onClick: { viewModel.toggleSpeakerMute() }
|
imageName: viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on",
|
||||||
)
|
onClick: { viewModel.toggleSpeakerMute() }
|
||||||
|
)
|
||||||
|
}
|
||||||
.padding(.bottom, 40)
|
.padding(.bottom, 40)
|
||||||
.padding(.trailing, 13.3)
|
.padding(.trailing, 13.3)
|
||||||
|
|
||||||
@@ -226,6 +262,18 @@ struct LiveRoomViewV2: View {
|
|||||||
onClick: { viewModel.toggleMute() }
|
onClick: { viewModel.toggleMute() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) {
|
||||||
|
LiveRoomRightBottomButton(
|
||||||
|
imageName: "ic_ice",
|
||||||
|
onClick: {
|
||||||
|
viewModel.setChatFreeze(isChatFrozen: !viewModel.isChatFrozen)
|
||||||
|
},
|
||||||
|
backgroundColor: viewModel.isChatFrozen
|
||||||
|
? Color(hex: "3bb9f1").opacity(0.5)
|
||||||
|
: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LiveRoomRightBottomButton(
|
LiveRoomRightBottomButton(
|
||||||
imageName: "ic_donation_message_list",
|
imageName: "ic_donation_message_list",
|
||||||
@@ -343,14 +391,21 @@ struct LiveRoomViewV2: View {
|
|||||||
.padding(.horizontal, 13.3)
|
.padding(.horizontal, 13.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
LiveRoomInputChatView {
|
LiveRoomInputChatView(
|
||||||
viewModel.sendMessage(chatMessage: $0) {
|
isInputDisabled: isChatInputDisabled,
|
||||||
viewModel.isShowingNewChat = false
|
sendMessage: {
|
||||||
proxy.scrollTo(viewModel.messages.count - 1, anchor: .center)
|
viewModel.sendMessage(chatMessage: $0) {
|
||||||
|
viewModel.isShowingNewChat = false
|
||||||
|
proxy.scrollTo(viewModel.messages.count - 1, anchor: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
onDisabledInputTap: {
|
||||||
|
viewModel.errorMessage = chatInputBlockedMessage
|
||||||
|
viewModel.isShowErrorPopup = true
|
||||||
}
|
}
|
||||||
|
)
|
||||||
return true
|
|
||||||
}
|
|
||||||
.padding(.top, isV2VCaptionVisible ? -13.3 : 0)
|
.padding(.top, isV2VCaptionVisible ? -13.3 : 0)
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
@@ -468,7 +523,7 @@ struct LiveRoomViewV2: View {
|
|||||||
}
|
}
|
||||||
.sodaToast(isPresented: $viewModel.isShowErrorPopup, message: viewModel.errorMessage, autohideIn: 1.3)
|
.sodaToast(isPresented: $viewModel.isShowErrorPopup, message: viewModel.errorMessage, autohideIn: 1.3)
|
||||||
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
||||||
.offset(y: -(keyboardHandler.keyboardHeight > 0 ? keyboardHandler.keyboardHeight : 0))
|
.offset(y: -appliedKeyboardHeight)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
@@ -570,6 +625,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 {
|
||||||
@@ -810,8 +883,8 @@ struct LiveRoomViewV2: View {
|
|||||||
.drawingGroup(opaque: false, colorMode: .linear)
|
.drawingGroup(opaque: false, colorMode: .linear)
|
||||||
}
|
}
|
||||||
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
||||||
.offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0)
|
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: keyboardHandler.keyboardHeight)
|
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
|
||||||
}
|
}
|
||||||
.onReceive(heartWaveTimer) { _ in
|
.onReceive(heartWaveTimer) { _ in
|
||||||
guard isLongPressingHeart else { return }
|
guard isLongPressingHeart else { return }
|
||||||
@@ -836,8 +909,13 @@ struct LiveRoomViewV2: View {
|
|||||||
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
|
||||||
viewModel.quitRoom()
|
viewModel.quitRoom()
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
|
||||||
|
if isFrozen {
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
.ignoresSafeArea(.keyboard)
|
.ignoresSafeArea(.keyboard)
|
||||||
.edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init())
|
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
|
||||||
.sheet(
|
.sheet(
|
||||||
isPresented: $viewModel.isShowShareView,
|
isPresented: $viewModel.isShowShareView,
|
||||||
onDismiss: { viewModel.shareMessage = "" },
|
onDismiss: { viewModel.shareMessage = "" },
|
||||||
@@ -939,6 +1017,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 {
|
||||||
|
|||||||
138
docs/20260319_라이브룸채팅삭제기능구현계획.md
Normal file
138
docs/20260319_라이브룸채팅삭제기능구현계획.md
Normal 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 미구성으로 자동 실행 불가.
|
||||||
224
docs/20260319_라이브룸채팅창얼리기기능구현계획.md
Normal file
224
docs/20260319_라이브룸채팅창얼리기기능구현계획.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# 20260319_라이브룸채팅창얼리기기능구현계획.md
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
- `LiveRoomViewV2` 기반 iOS 라이브룸에 채팅창 얼리기(Freeze) 토글을 추가한다.
|
||||||
|
- 채팅창 얼리기 상태에서는 방장을 제외한 모든 사용자가 채팅 입력/전송을 할 수 없어야 한다.
|
||||||
|
- 상태는 지연 입장 사용자까지 일관되게 적용되도록 `ROOM_INFO` 기반으로 동기화한다.
|
||||||
|
- 본 문서는 **구현 계획 및 실행 추적 문서**이며, 체크리스트/검증 기록을 통해 실제 반영 상태를 함께 관리한다.
|
||||||
|
|
||||||
|
## 요구사항 요약
|
||||||
|
- 토글 버튼 위치: `LiveRoomInfoHostView` 상단 토글 영역의 `시그 ON/OFF` 버튼 왼쪽.
|
||||||
|
- 얼림(ON): 방장을 제외한 전체 유저 채팅 입력 불가(포커스/입력/전송 모두 차단).
|
||||||
|
- 녹임(OFF): 채팅금지 해제와 동일하게 즉시 채팅 가능 상태로 복귀.
|
||||||
|
- 상태 메시지: 얼림/녹임 시 모든 유저 채팅 리스트에 시스템 상태 메시지 1회 노출.
|
||||||
|
- 상태 메시지 UI: 사용자 입장 알림(`LiveRoomJoinChatItemView`)과 동일한 스타일 사용.
|
||||||
|
- 지연 입장: 채팅창이 얼려진 상태로 입장한 사용자도 즉시 상태를 받아 입력 불가여야 함.
|
||||||
|
- 지연 입장 + 얼림 상태: `ROOM_INFO.isChatFrozen == true`인 경우 채팅 리스트에 얼림 상태 메시지를 즉시 1회 노출해야 함.
|
||||||
|
- 얼림 상태 입력 피드백: 사용자가 채팅 입력 영역(입력창/전송 버튼)을 터치하면 차단 안내 토스트를 노출해야 함.
|
||||||
|
- 얼림 상태 포커스 UX: 키보드가 올라오지 않는 상황에서 레이아웃이 키보드 높이만큼 밀리지 않아야 함.
|
||||||
|
- 상단 토글 라벨은 `얼림 ON/OFF` 문구를 사용한다.
|
||||||
|
- 상태 변경 패턴: 룰렛과 동일하게 서버 API 선반영 후, 성공 시 RTM 브로드캐스트 전파.
|
||||||
|
|
||||||
|
## 상태 저장 전략 판단 (ROOM_INFO vs 별도 상태)
|
||||||
|
### 결론
|
||||||
|
- **`ROOM_INFO`에 `isChatFrozen` 상태를 저장**하고, RTM 이벤트는 즉시 반영용으로 병행한다.
|
||||||
|
|
||||||
|
### 판단 근거
|
||||||
|
- `LiveRoomViewModel.getRoomInfo`가 `liveRoomInfo`를 갱신하며 룸 전역 상태(`isActiveRoulette`)를 적용한다.
|
||||||
|
- `rtmKit(_:didReceivePresenceEvent:)`의 `remoteJoin` 시점에 `getRoomInfo(userId:onSuccess:)`를 재호출해 지연 입장 상태를 복원한다.
|
||||||
|
- 룸 전역 상태 선례가 이미 존재한다.
|
||||||
|
- `GetRoomInfoResponse.isActiveRoulette`
|
||||||
|
- `LiveRoomChatRawMessageType.TOGGLE_ROULETTE`
|
||||||
|
- `LiveRoomViewModel` RTM 수신 분기(`decoded.type == .TOGGLE_ROULETTE`)
|
||||||
|
- 기존 `NO_CHATTING`은 `UserDefaults.noChatRoomList` 기반 로컬 제어라 방 전체 상태의 단일 진실원천(SSOT)으로는 부적합하다.
|
||||||
|
|
||||||
|
### 외부 레퍼런스(요약)
|
||||||
|
- Agora Signaling channel metadata: 저장소 기반 상태 유지 + 변경 이벤트 전파를 공식 제공.
|
||||||
|
- https://docs.agora.io/en/signaling/core-functionality/store-channel-metadata
|
||||||
|
- Agora Signaling message channel: pub/sub 실시간 브로드캐스트 모델 설명.
|
||||||
|
- https://docs.agora.io/en/signaling/core-functionality/message-channel
|
||||||
|
- Stream Chat iOS: `ChatChannel.isFrozen` 권위 상태 + `channel.updated` 이벤트 병합 패턴.
|
||||||
|
- https://github.com/GetStream/stream-chat-swift
|
||||||
|
|
||||||
|
## 완료 기준 (Acceptance Criteria)
|
||||||
|
- [ ] AC1: 방장이 얼림 ON 시 방장을 제외한 사용자는 `LiveRoomInputChatView`에서 포커스/입력/전송이 모두 불가능하다.
|
||||||
|
- [ ] AC2: 방장이 얼림 OFF 시 방장을 제외한 사용자의 채팅 입력/전송이 즉시 복구된다.
|
||||||
|
- [ ] AC3: 얼림/녹임 이벤트마다 모든 사용자 채팅 리스트에 시스템 상태 메시지가 1회씩 노출된다.
|
||||||
|
- [ ] AC4: 얼림 상태에서 새로 입장한 사용자는 입장 직후 입력 불가 상태를 즉시 적용받는다.
|
||||||
|
- [ ] AC5: 얼리기 토글 버튼은 `LiveRoomInfoHostView`에서 `시그 ON/OFF` 버튼의 왼쪽에 배치된다.
|
||||||
|
- [ ] AC6: 방장 얼림 ON/OFF 시 서버 API가 선행 호출되고, 성공한 경우에만 RTM 상태 브로드캐스트가 전송된다.
|
||||||
|
- [ ] AC7: 지연 입장 시 `ROOM_INFO.isChatFrozen == true`이면 채팅 리스트에 얼림 상태 메시지가 1회 표시된다.
|
||||||
|
- [ ] AC8: 채팅 얼림 상태에서 입력 영역 터치 시 `chatFreezeBlockedMessage` 토스트가 표시된다.
|
||||||
|
- [ ] AC9: 채팅 얼림 상태 입력 포커스 시 키보드 미노출 상태에서 화면 오프셋 밀림이 발생하지 않는다.
|
||||||
|
- [ ] AC10: 지연 입장으로 얼림 상태를 받은 사용자는 방장 해제(RTM `TOGGLE_CHAT_FREEZE=false`) 직후 입력이 즉시 가능해야 한다.
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
### 1) UI/입력 제어
|
||||||
|
- [x] `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoHostView.swift`에 얼리기 토글 UI 및 콜백 추가(`onClickToggleSignature` 왼쪽 배치).
|
||||||
|
- [x] `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`에서 호스트 토글 액션 바인딩(`LiveRoomInfoHostView` 인자 확장).
|
||||||
|
- [x] `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`에 전역 상태(`isChatFrozen`) 및 권한 판별(방장 제외 차단) 로직 추가.
|
||||||
|
- [x] `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInputChatView.swift`와 `SodaLive/Sources/CustomView/ChatTextFieldView.swift`에 비활성 상태 반영(입력/전송 버튼 차단).
|
||||||
|
- [x] `LiveRoomViewModel.sendMessage` 경로에 Freeze 가드 추가(서버/RTM 지연 시에도 전송 방지).
|
||||||
|
- [x] `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` 키보드 오프셋 계산에 `isChatFrozenForCurrentUser` 가드를 추가해 얼림 상태에서 레이아웃 밀림을 차단.
|
||||||
|
- [x] `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInputChatView.swift`에 입력창/전송 버튼 터치 차단 콜백을 추가해 얼림 상태 터치 시 토스트를 노출.
|
||||||
|
- [x] `SodaLive/Sources/CustomView/ChatTextFieldView.swift`에서 `Coordinator`가 최신 `isEnabled`를 참조하도록 상태 동기화 보완(지연 입장 후 해제 불가 버그 수정).
|
||||||
|
|
||||||
|
### 2) 상태 전파/수신
|
||||||
|
- [x] 서버 API 경로 추가: 얼림 상태 변경 endpoint 및 request DTO 추가(`LiveApi`/`LiveRepository`/request 모델).
|
||||||
|
- [x] 방장 토글 액션은 API 성공 콜백에서만 RTM 브로드캐스트를 전송하도록 순서 보장(룰렛과 동일).
|
||||||
|
- [x] API 실패 시 RTM 미전송 + 오류 메시지 표시 시나리오 반영.
|
||||||
|
- [x] `LiveRoomChatRawMessageType`에 Freeze 이벤트 타입 추가(예: `TOGGLE_CHAT_FREEZE`).
|
||||||
|
- [x] `LiveRoomChatRawMessage`에 Freeze 상태 전달 필드 추가(예: `isChatFrozen: Bool?`).
|
||||||
|
- [x] `LiveRoomViewModel.rtmKit(_:didReceiveMessageEvent:)`에 Freeze 수신 분기 추가.
|
||||||
|
|
||||||
|
### 3) 지연 입장 동기화
|
||||||
|
- [x] `SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift`에 `isChatFrozen` 필드 추가.
|
||||||
|
- [x] `LiveRoomViewModel.getRoomInfo`에서 `isChatFrozen`을 UI 입력 제어 상태에 반영.
|
||||||
|
- [x] `onAppear`, `didJoinedOfUid`, `didReceivePresenceEvent(remoteJoin)`의 기존 `getRoomInfo` 재조회 흐름으로 상태 재적용 경로 유지.
|
||||||
|
- [x] `LiveRoomViewModel.getRoomInfo` 초기 동기화에서 `isChatFrozen == true`일 때 채팅 얼림 상태 메시지를 1회 주입.
|
||||||
|
|
||||||
|
### 4) 시스템 메시지(UI 동일성)
|
||||||
|
- [x] 입장 알림과 동일한 스타일을 재사용할 수 있도록 시스템 메시지 모델 경로 확장(`LiveRoomChat`/`LiveRoomJoinChatItemView`).
|
||||||
|
- [x] 얼림/녹임 메시지를 `messages.append(...)` + `invalidateChat()` 경로로 주입.
|
||||||
|
- [x] 자기 메시지 self-echo 중복 방지를 위해 발신자 로컬 주입 + 수신 분기에서 self 제외 처리.
|
||||||
|
|
||||||
|
### 5) 문자열/국제화
|
||||||
|
- [x] `SodaLive/Sources/I18n/I18n.swift`의 `I18n.LiveRoom`에 Freeze 토글 라벨 및 상태 메시지 문구 추가.
|
||||||
|
- [x] `ko/en/ja` 3개 언어 키 셋을 동일 범위로 정의.
|
||||||
|
- [x] Freeze 토글 라벨을 `얼림 ON/OFF` 문구로 조정.
|
||||||
|
|
||||||
|
### 6) 검증
|
||||||
|
- [x] 정적 진단: 수정 파일 대상 `lsp_diagnostics` 확인.
|
||||||
|
- [x] 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`.
|
||||||
|
- [x] 빌드(개발 스킴): `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`.
|
||||||
|
- [x] 테스트 시도: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` 및 `SodaLive-dev test` 실행(두 스킴 모두 test action 미구성 확인).
|
||||||
|
- [ ] 수동 QA: 방장/일반유저 2계정으로 ON/OFF, 지연 입장, 재연결, 메시지 노출 시나리오 검증.
|
||||||
|
|
||||||
|
## 영향 파일(예상)
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoHostView.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInputChatView.swift`
|
||||||
|
- `SodaLive/Sources/CustomView/ChatTextFieldView.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift`
|
||||||
|
- `SodaLive/Sources/Live/LiveApi.swift`
|
||||||
|
- `SodaLive/Sources/Live/LiveRepository.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift` (`SetChatFreezeRequest` 추가)
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
|
||||||
|
## 리스크 및 의존성
|
||||||
|
- 서버 `ROOM_INFO` 응답에 `isChatFrozen` 필드가 제공되지 않으면 지연 입장 정합성 보장이 어렵다.
|
||||||
|
- RTM 메시지 단독 구현 시 pub/sub 특성상 지연 입장 사용자에게 과거 상태 스냅샷 누락 위험이 있다.
|
||||||
|
- API 성공 이전 RTM 전송 시 서버 상태와 클라이언트 UI 불일치가 발생할 수 있으므로, 전송 순서를 API 성공 이후로 강제해야 한다.
|
||||||
|
- 입력 차단을 전송 가드만으로 처리하면 키보드 입력은 가능해 요구사항(입력 자체 불가)을 만족하지 못하므로 `TextField` 비활성 처리가 필요하다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-03-19 (초안 조사)
|
||||||
|
- 무엇/왜/어떻게: 채팅창 얼리기 기능의 저장 전략 판단을 위해 LiveRoom 내부 구현 패턴, RTM 메시지 경로, ROOM_INFO 동기화 지점을 조사하고 계획 초안을 정리했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `task(subagent_type="explore")` x3 (no-chat 흐름, room state sync, system UI 패턴)
|
||||||
|
- `task(subagent_type="librarian")` x2 (Agora 상태 전파 문서/실사례)
|
||||||
|
- `grep("isNoChatting|NO_CHATTING|...")`, `grep("LiveRoomChatRawMessageType|ROOM_INFO|...")`
|
||||||
|
- `ast_grep_search("LiveRoomChatRawMessage($$$)")`
|
||||||
|
- `read(LiveRoomViewModel.swift, GetRoomInfoResponse.swift, LiveRoomViewV2.swift 등)`
|
||||||
|
- 결과:
|
||||||
|
- no-chat 기존 구현(로컬 저장 + peer 명령 + 타이머)과 ROOM_INFO 기반 전역 상태 동기화 패턴을 분리 확인.
|
||||||
|
- `isActiveRoulette` 선례를 통해 ROOM_INFO + RTM 병행 전략이 지연 입장 정합성에 유리함을 확인.
|
||||||
|
- 시스템 메시지 UI는 `LiveRoomJoinChatItemView` 스타일 재사용이 요구사항에 부합함을 확인.
|
||||||
|
|
||||||
|
- 2026-03-19 (iOS 계획 전환)
|
||||||
|
- 무엇/왜/어떻게: Android 용어/경로 중심 문서를 현재 iOS 프로젝트 구조 기준으로 전면 변환하고, 사용자 요구사항(문서 작업만)을 충족하는 구현 체크리스트로 재작성했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `task(subagent_type="explore")` x2 (V2 상태 토글 흐름, 시스템 메시지 렌더 경로)
|
||||||
|
- `task(subagent_type="librarian")` x2 (Agora/Stream iOS 레퍼런스)
|
||||||
|
- `grep("getRoomInfo|TOGGLE_ROULETTE|NO_CHATTING|...")`
|
||||||
|
- `read(LiveRoomViewModel.swift, LiveRoomChat.swift, LiveRoomChatRawMessage.swift, LiveApi.swift, LiveRepository.swift, LiveRoomInfoHostView.swift, LiveRoomInputChatView.swift, I18n.swift)`
|
||||||
|
- 결과:
|
||||||
|
- `ROOM_INFO + RTM` 병행, `API 선반영 후 RTM 전파`, `지연 입장 재동기화`를 iOS 실제 코드 경로에 매핑했다.
|
||||||
|
- 토글 위치/입력 차단/시스템 메시지/국제화/검증 명령을 iOS 파일 및 스킴 기준으로 구체화했다.
|
||||||
|
|
||||||
|
- 2026-03-19 (iOS 기능 구현)
|
||||||
|
- 무엇/왜/어떻게: 계획 문서 체크리스트를 기준으로 채팅창 얼리기 기능을 iOS 코드에 구현했다. 방장 토글 UI, 서버 API 선반영 후 RTM 브로드캐스트, ROOM_INFO 기반 지연 입장 동기화, 입장 알림 스타일 시스템 메시지, 비방장 입력 차단을 한 흐름으로 연결했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `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`
|
||||||
|
- `lsp_diagnostics(수정 파일 전수)`
|
||||||
|
- 결과:
|
||||||
|
- `SodaLive`/`SodaLive-dev` Debug 빌드 성공.
|
||||||
|
- 두 스킴 모두 test action 미구성으로 자동 테스트 실행 불가(`Scheme ... is not currently configured for the test action`).
|
||||||
|
- `lsp_diagnostics`는 워크스페이스 외부 모듈 해석 제약으로 다수 false positive가 표시되었고, 실제 컴파일 유효성은 `xcodebuild` 성공으로 확인.
|
||||||
|
- 수동 QA(2계정 실기기/시뮬레이터 시나리오)는 로컬 앱 실행 환경에서 후속 수행 필요.
|
||||||
|
|
||||||
|
- 2026-03-19 (Oracle 리뷰 반영)
|
||||||
|
- 무엇/왜/어떻게: 구현 후 Oracle 리뷰에서 `TOGGLE_CHAT_FREEZE` 수신 시 발신자 권한 검증 누락(비방장 RTM 수용 가능) 이슈를 확인해, RTM 수신 분기에 `publisher == creatorId` 검증을 추가했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `task(subagent_type="oracle")` (구현 검토)
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||||
|
- 결과:
|
||||||
|
- 비방장 발신 `TOGGLE_CHAT_FREEZE`는 무시되도록 보강.
|
||||||
|
- 보강 후 `SodaLive`/`SodaLive-dev` Debug 빌드 재검증 성공.
|
||||||
|
|
||||||
|
- 2026-03-19 (입력 포커스 키보드 밀림 보완)
|
||||||
|
- 무엇/왜/어떻게: 채팅 얼림 상태에서 키보드가 뜨지 않는데 화면만 밀리는 이슈를 보완하기 위해 입력 포커스 획득을 차단하고, 얼림 상태에서는 키보드 오프셋 적용을 비활성화했다. 동시에 토글 라벨 문구를 `얼림 ON/OFF`로 조정했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `task(subagent_type="explore")` (키보드 오프셋 경로 분석)
|
||||||
|
- `read(ChatTextFieldView.swift, LiveRoomInputChatView.swift, LiveRoomViewV2.swift, I18n.swift)`
|
||||||
|
- `lsp_diagnostics(ChatTextFieldView.swift, LiveRoomInputChatView.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`
|
||||||
|
- 결과:
|
||||||
|
- 빌드: 두 스킴 모두 `** BUILD SUCCEEDED **` 확인.
|
||||||
|
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 자동 테스트 실행 불가.
|
||||||
|
- `lsp_diagnostics`는 단일 파일 분석 한계로 외부 모듈/확장 미해석 오류가 남았으나, 실제 컴파일 유효성은 `xcodebuild` 성공으로 확인.
|
||||||
|
|
||||||
|
- 2026-03-19 (지연 입장 메시지/입력 터치 토스트 보완)
|
||||||
|
- 무엇/왜/어떻게: 지연 입장 시 얼림 상태 인지성을 높이기 위해 `getRoomInfo` 초기 동기화에서 `isChatFrozen == true`면 상태 메시지를 주입했고, 얼림 상태에서 입력 영역 터치 시 차단 토스트를 즉시 노출하도록 입력 콜백을 연결했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `task(subagent_type="explore")` x2 (지연 입장 동기화 경로, 입력 차단 토스트 패턴)
|
||||||
|
- `grep("isChatFrozenForCurrentUser|appendChatFreezeStatusMessage|getRoomInfo|isShowErrorPopup", include:"*.swift")`
|
||||||
|
- `read(LiveRoomViewModel.swift, LiveRoomInputChatView.swift, LiveRoomViewV2.swift)`
|
||||||
|
- `lsp_diagnostics(LiveRoomViewModel.swift, LiveRoomInputChatView.swift, LiveRoomViewV2.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`
|
||||||
|
- 결과:
|
||||||
|
- 구현 지점(`getRoomInfo`, 입력 터치 콜백, 에러 토스트 연결)을 반영.
|
||||||
|
- 빌드: 두 스킴 모두 `** BUILD SUCCEEDED **` 확인.
|
||||||
|
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 자동 테스트 실행 불가.
|
||||||
|
- `lsp_diagnostics`는 단일 파일 분석 한계로 외부 모듈/확장 미해석 오류가 남았으나, 실제 컴파일 유효성은 `xcodebuild` 성공으로 확인.
|
||||||
|
- 동일 기능 기준으로 분리 문서 2개를 본 문서로 통합해 단일 추적 문서 체계로 정리.
|
||||||
|
- 수동 QA(지연 입장 시 얼림 메시지 노출, 얼림 상태 입력영역 터치 토스트)는 로컬 앱 실행 환경에서 후속 수행 필요.
|
||||||
|
|
||||||
|
- 2026-03-19 (지연 입장 후 해제 불가 버그 분석)
|
||||||
|
- 무엇/왜/어떻게: `isChatFrozen` 해제 후에도 입력이 막히는 이슈를 재현 경로 기준으로 분석했다. 원인은 `ChatTextFieldView.Coordinator`가 최초 생성 시점 `parent.isEnabled`를 계속 참조하고, `updateUIView`에서 최신 parent로 갱신하지 않아 `textFieldShouldBeginEditing`이 계속 false를 반환하는 상태 고착이었다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `task(subagent_type="explore")` (지연 입장/해제 상태 전이 분석)
|
||||||
|
- `read(ChatTextFieldView.swift, LiveRoomInputChatView.swift, LiveRoomViewV2.swift, LiveRoomViewModel.swift)`
|
||||||
|
- `grep("textFieldShouldBeginEditing|Coordinator\(|isInputDisabled|TOGGLE_CHAT_FREEZE", include:"*.swift")`
|
||||||
|
- 결과:
|
||||||
|
- 수정 지점을 `ChatTextFieldView.updateUIView`로 확정.
|
||||||
|
|
||||||
|
- 2026-03-19 (지연 입장 후 해제 불가 버그 수정)
|
||||||
|
- 무엇/왜/어떻게: 지연 입장 사용자에게서 얼림 해제 후에도 입력이 막히는 현상을 해결하기 위해 `ChatTextFieldView.updateUIView`에서 `context.coordinator.parent = self`를 적용해 coordinator가 최신 `isEnabled` 상태를 항상 참조하도록 보정했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `apply_patch(ChatTextFieldView.updateUIView)`
|
||||||
|
- `lsp_diagnostics(ChatTextFieldView.swift, LiveRoomViewModel.swift, LiveRoomInputChatView.swift, LiveRoomViewV2.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`
|
||||||
|
- 결과:
|
||||||
|
- 빌드: 두 스킴 모두 `** BUILD SUCCEEDED **` 확인.
|
||||||
|
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 자동 테스트 실행 불가.
|
||||||
|
- `lsp_diagnostics`는 워크스페이스 모듈 해석 한계로 단일 파일 false positive가 남았으나, 컴파일 유효성은 `xcodebuild` 성공으로 확인.
|
||||||
|
- 수동 QA(지연 입장 -> 방장 해제 -> 입력 가능 전환)는 로컬 앱 실행 환경에서 후속 확인 필요.
|
||||||
41
docs/20260319_채팅금지상태알림방식수정.md
Normal file
41
docs/20260319_채팅금지상태알림방식수정.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 20260319_채팅금지상태알림방식수정.md
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
- 라이브룸 V2에서 `채팅 금지` 상태 알림 방식을 `채팅창 얼림` 상태와 동일한 UX로 맞춘다.
|
||||||
|
- 현재는 입력 후 전송 시점에만 차단 안내가 표시되므로, 입력창 터치 시점 안내로 변경한다.
|
||||||
|
|
||||||
|
## 완료 기준 (Acceptance Criteria)
|
||||||
|
- [x] AC1: `채팅 금지` 상태에서 입력창을 터치하면 즉시 토스트가 노출된다.
|
||||||
|
- [x] AC2: `채팅 금지` 상태에서 전송 버튼을 눌러도 입력창 터치와 동일한 차단 안내가 일관되게 동작한다.
|
||||||
|
- [x] AC3: 기존 `채팅창 얼림` 상태의 토스트 문구/노출 방식과 동일한 경로를 재사용한다.
|
||||||
|
- [x] AC4: 빌드 검증(`SodaLive`, `SodaLive-dev`)이 통과한다.
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
- [x] `LiveRoomViewV2`와 입력 컴포넌트 연결 지점에서 `채팅 금지` 상태를 입력 비활성 조건에 포함한다.
|
||||||
|
- [x] `LiveRoomInputChatView`의 비활성 입력 터치 콜백 경로를 `채팅 금지` 상태에도 동일 적용한다.
|
||||||
|
- [x] 차단 안내 토스트 노출 경로를 단일화해 입력창 터치 시점 피드백이 보장되도록 조정한다.
|
||||||
|
- [x] `lsp_diagnostics` 및 `xcodebuild` 검증 결과를 기록한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-03-19 (초안 작성)
|
||||||
|
- 무엇/왜/어떻게: 사용자 요청(채팅 금지 알림 시점을 입력 터치 시점으로 변경)에 맞춘 최소 범위 작업 계획을 수립했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `read(LiveRoomViewV2.swift, LiveRoomInputChatView.swift)`
|
||||||
|
- `grep("onDisabledInputTap|isInputDisabled|chatFreezeBlockedMessage", include:"*.swift")`
|
||||||
|
- 결과:
|
||||||
|
- 변경 지점 후보를 `LiveRoomViewV2` 입력 바인딩과 `LiveRoomInputChatView` 비활성 터치 처리로 식별했다.
|
||||||
|
|
||||||
|
- 2026-03-19 (구현 및 검증)
|
||||||
|
- 무엇/왜/어떻게: `채팅 금지` 상태를 `LiveRoomInputChatView` 비활성 조건에 포함하고, 비활성 입력 터치 시 토스트 메시지가 `채팅창 얼림`과 동일 경로(`isShowErrorPopup`)로 노출되도록 수정했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `apply_patch(LiveRoomViewV2.swift)`
|
||||||
|
- `lsp_diagnostics(LiveRoomViewV2.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`
|
||||||
|
- 결과:
|
||||||
|
- `LiveRoomViewV2`에서 `isChatFrozenForCurrentUser || isNoChatting`를 입력 비활성 조건으로 적용.
|
||||||
|
- 비활성 입력 터치 시 `chatInputBlockedMessage`를 통해 얼림/채팅금지 각각 맞는 문구를 토스트로 노출하도록 반영.
|
||||||
|
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **` 확인.
|
||||||
|
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`으로 자동 테스트 미구성 상태 확인.
|
||||||
31
docs/20260320_채팅얼림아이콘이동및문구점검.md
Normal file
31
docs/20260320_채팅얼림아이콘이동및문구점검.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 20260320 채팅 얼림 아이콘 이동 및 문구 점검
|
||||||
|
|
||||||
|
## 작업 체크리스트
|
||||||
|
- [x] `LiveRoomViewV2` 우측 하단 버튼 영역에서 방장용 채팅 얼림(`ic_ice`) 아이콘을 마이크 음소거 아이콘 아래로 이동한다.
|
||||||
|
- [x] 채팅 얼림 상태에서 입력 시 노출되는 `I18n.LiveRoom.chatFreezeBlockedMessage` 문구가 한국어 `🧊 채팅창이 얼었습니다.`인지 확인한다.
|
||||||
|
- [x] 동일 키의 영어/일본어 번역이 한국어 의미에 맞게 유지되는지 확인한다.
|
||||||
|
- [x] 수정 파일 진단과 빌드를 실행해 결과를 기록한다.
|
||||||
|
|
||||||
|
## 완료 기준 (Pass/Fail)
|
||||||
|
- [x] Pass: 방장 화면에서 `ic_ice` 버튼이 `ic_mic_on`/`ic_mic_off` 버튼 바로 아래 순서로 렌더링된다. (QA: 버튼 VStack 순서 코드 확인)
|
||||||
|
- [x] Pass: 채팅 얼림 입력 차단 문구가 한국어 `🧊 채팅창이 얼었습니다.`로 유지된다. (QA: `I18n.LiveRoom.chatFreezeBlockedMessage` 값 확인)
|
||||||
|
- [x] Pass: 영어/일본어 번역이 각각 `🧊 The chat is now frozen.`, `🧊 チャットが凍結されました。`로 확인된다. (QA: 동일 키 다국어 값 확인)
|
||||||
|
- [ ] Pass: 수정 파일 LSP 진단 에러 0건, 빌드 명령 종료 코드 0. (QA: `lsp_diagnostics`, `xcodebuild`)
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-03-20 (채팅 얼림 아이콘 위치 및 문구 점검)
|
||||||
|
- 무엇/왜/어떻게: `LiveRoomViewV2` 우측 버튼 배치에서 기존 상단 스피커 토글 묶음의 얼림 버튼을 제거하고, 마이크 음소거 버튼 분기 바로 아래에 동일 버튼/동작을 이동했다. 동시에 입력 차단 토스트가 참조하는 `I18n.LiveRoom.chatFreezeBlockedMessage`의 ko/en/ja 문구를 점검해 요구 문구/번역과 일치함을 확인했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `lsp_diagnostics`:
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.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`
|
||||||
|
- `python3` 소스 QA 스크립트 (아이콘 순서/문구 매칭 검증)
|
||||||
|
- 결과:
|
||||||
|
- `lsp_diagnostics`: SourceKit `No such module 'Kingfisher'` 진단 발생(의존성 인덱싱 환경 이슈로 판단, 수정 코드 문법 오류는 `xcodebuild` 성공으로 교차 확인).
|
||||||
|
- `SodaLive` Debug build: `** BUILD SUCCEEDED **`.
|
||||||
|
- `SodaLive-dev` Debug build: `** BUILD SUCCEEDED **`.
|
||||||
|
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가.
|
||||||
|
- 소스 QA 스크립트: `mic_button_exists`, `ice_button_exists`, `speaker_button_exists`, `ice_is_below_mic_in_code_order`, `ice_not_in_top_speaker_group`, `chat_freeze_blocked_message_i18n_values_match` 전 항목 `PASS`.
|
||||||
35
docs/20260320_채팅창얼림버튼및문구수정.md
Normal file
35
docs/20260320_채팅창얼림버튼및문구수정.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 20260320 채팅창 얼림 버튼 및 문구 수정
|
||||||
|
|
||||||
|
## 작업 체크리스트
|
||||||
|
- [x] `LiveRoomViewV2`에서 방장 전용 얼림 버튼을 스피커 음소거 버튼 위에 배치한다.
|
||||||
|
- [x] 얼림 버튼 OFF/ON 상태별 배경 스타일을 요구사항(기본 배경 / `#3bb9f1` 50%, corner radius 10)로 반영한다.
|
||||||
|
- [x] 얼림 ON/OFF 시 채팅 문구를 방장/리스너 조건으로 각각 지정된 문구로 수정한다.
|
||||||
|
- [x] 수정 파일 진단 및 빌드를 실행하고 결과를 기록한다.
|
||||||
|
|
||||||
|
## 완료 기준 (Pass/Fail)
|
||||||
|
- [x] Pass: 방장 계정에서만 `ic_ice` 버튼이 보이고, 버튼이 스피커 음소거 버튼 바로 위에 위치한다. (QA: 화면 렌더링 코드 조건/배치 확인)
|
||||||
|
- [x] Pass: OFF 상태 배경은 우측 하단 기존 버튼과 동일하고, ON 상태는 `#3bb9f1` 50% + radius 10으로 적용된다. (QA: 버튼 스타일 코드 확인)
|
||||||
|
- [x] Pass: 얼림 ON/OFF 채팅 문구가 방장/리스너 조건에 맞게 정확히 분기된다. (QA: 얼림 메시지 생성 코드 확인)
|
||||||
|
- [x] Pass: 수정 파일 LSP 진단 에러 0, 빌드 명령 종료 코드 0. (QA: `lsp_diagnostics`, `xcodebuild`)
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-03-20 (채팅창 얼림 버튼/문구 수정)
|
||||||
|
- 무엇/왜/어떻게: 얼림 토글을 상단 호스트 토글 영역에서 우측 하단 스피커 음소거 버튼 위로 이동하고, ON/OFF 배경 스펙 및 방장 전용 노출 조건을 반영했다. 동시에 얼림 상태 채팅 문구를 방장/리스너 역할 기준으로 분기되도록 `LiveRoomViewModel` + `I18n` 경로를 수정했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `lsp_diagnostics`:
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/Component/Button/LiveRoomRightBottomButton.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoHostView.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
|
||||||
|
- `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`
|
||||||
|
- `SodaLive/Sources/I18n/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`
|
||||||
|
- `python3` 코드 QA 스크립트(버튼 위치/호스트 노출/문구 분기 문자열 정합성 PASS 체크)
|
||||||
|
- 결과:
|
||||||
|
- `lsp_diagnostics` 대상 5개 파일 모두 `No diagnostics found` 확인.
|
||||||
|
- `SodaLive` Debug build: `** BUILD SUCCEEDED **`.
|
||||||
|
- `SodaLive-dev` Debug build: `** BUILD SUCCEEDED **`.
|
||||||
|
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 자동 테스트 실행 불가.
|
||||||
|
- 코드 QA 스크립트: `host_only_ice_button`, `ice_above_speaker`, `host_header_toggle_removed`, `on_message_creator`, `on_message_listener`, `off_message_common`, `viewmodel_role_branch` 전 항목 `PASS`.
|
||||||
Reference in New Issue
Block a user