diff --git a/SodaLive/Sources/CustomView/ChatTextFieldView.swift b/SodaLive/Sources/CustomView/ChatTextFieldView.swift index 6f5b62a..3c3dd07 100644 --- a/SodaLive/Sources/CustomView/ChatTextFieldView.swift +++ b/SodaLive/Sources/CustomView/ChatTextFieldView.swift @@ -22,6 +22,10 @@ struct ChatTextFieldView: UIViewRepresentable { return true } + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + return parent.isEnabled + } + @objc func textDidChange(_ textField: UITextField) { parent.text = textField.text ?? "" } @@ -29,6 +33,7 @@ struct ChatTextFieldView: UIViewRepresentable { @Binding var text: String var placeholder: String + var isEnabled: Bool = true var onSend: () -> Void func makeUIView(context: Context) -> UITextField { @@ -44,6 +49,7 @@ struct ChatTextFieldView: UIViewRepresentable { textField.tintColor = UIColor(hex: "3BB9F1") textField.font = UIFont(name: Font.preMedium.rawValue, size: 13.3) textField.returnKeyType = .send + textField.isEnabled = isEnabled textField.setContentHuggingPriority(.defaultLow, for: .horizontal) // 우선순위 낮추기 textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) // 우선순위 낮추기 textField.addTarget(context.coordinator, action: #selector(Coordinator.textDidChange(_:)), for: .editingChanged) @@ -51,7 +57,13 @@ struct ChatTextFieldView: UIViewRepresentable { } func updateUIView(_ uiView: UITextField, context: Context) { + context.coordinator.parent = self uiView.text = text + uiView.isEnabled = isEnabled + + if !isEnabled && uiView.isFirstResponder { + uiView.resignFirstResponder() + } } func makeCoordinator() -> Coordinator { diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 9dd234d..5e20a22 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -773,6 +773,8 @@ enum I18n { 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 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 captionOff: String { pick(ko: "자막 OFF", en: "Caption OFF", ja: "字幕 OFF") } static var backgroundOn: String { pick(ko: "배경 ON", en: "Back ON", ja: "背景 ON") } @@ -782,6 +784,9 @@ enum I18n { static var participants: String { pick(ko: "참여자", en: "Participants", ja: "リスナー") } static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") } static var following: String { pick(ko: "팔로잉", en: "Following", 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 chatFreezeBlockedMessage: String { pick(ko: "채팅창이 얼려져 있어 채팅할 수 없습니다.", en: "You cannot chat while chat is frozen.", ja: "チャットが凍結中のため送信できません。") } } enum LiveNow { diff --git a/SodaLive/Sources/Live/LiveApi.swift b/SodaLive/Sources/Live/LiveApi.swift index 49470fd..e3a5058 100644 --- a/SodaLive/Sources/Live/LiveApi.swift +++ b/SodaLive/Sources/Live/LiveApi.swift @@ -30,6 +30,7 @@ enum LiveApi { case setListener(request: SetManagerOrSpeakerOrAudienceRequest) case setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest) case setManager(request: SetManagerOrSpeakerOrAudienceRequest) + case setChatFreeze(request: SetChatFreezeRequest) case kickOut(request: LiveRoomKickOutRequest) case donationStatus(roomId: Int) case donationTotal(roomId: Int) @@ -112,6 +113,9 @@ extension LiveApi: TargetType { case .setManager: return "/live/room/info/set/manager" + + case .setChatFreeze: + return "/live/room/info/set/chat-freeze" case .kickOut: return "/live/room/kick-out" @@ -156,7 +160,7 @@ extension LiveApi: TargetType { case .makeReservation, .enterRoom, .createRoom, .quitRoom, .donation, .refundDonation, .kickOut, .likeHeart: return .post - case .setListener, .setSpeaker, .setManager, .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo: + case .setListener, .setSpeaker, .setManager, .setChatFreeze, .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo: return .put case .deleteDonationMessage: @@ -237,6 +241,9 @@ extension LiveApi: TargetType { case .setListener(let request), .setSpeaker(let request), .setManager(let request): return .requestJSONEncodable(request) + + case .setChatFreeze(let request): + return .requestJSONEncodable(request) case .kickOut(let request): return .requestJSONEncodable(request) diff --git a/SodaLive/Sources/Live/LiveRepository.swift b/SodaLive/Sources/Live/LiveRepository.swift index c491dd8..9094910 100644 --- a/SodaLive/Sources/Live/LiveRepository.swift +++ b/SodaLive/Sources/Live/LiveRepository.swift @@ -92,6 +92,10 @@ final class LiveRepository { func setManager(roomId: Int, userId: Int) -> AnyPublisher { return api.requestPublisher(.setManager(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId))) } + + func setChatFreeze(roomId: Int, isChatFrozen: Bool) -> AnyPublisher { + return api.requestPublisher(.setChatFreeze(request: SetChatFreezeRequest(roomId: roomId, isChatFrozen: isChatFrozen))) + } func kickOut(roomId: Int, userId: Int) -> AnyPublisher { return api.requestPublisher(.kickOut(request: LiveRoomKickOutRequest(roomId: roomId, userId: userId))) diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift index 3c72115..79cca09 100644 --- a/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift @@ -46,6 +46,12 @@ struct LiveRoomRouletteDonationChat: LiveRoomChat { struct LiveRoomJoinChat: LiveRoomChat { let nickname: String + let statusMessage: String? var type: LiveRoomChatType = .JOIN + + init(nickname: String, statusMessage: String? = nil) { + self.nickname = nickname + self.statusMessage = statusMessage + } } diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift index cf50ced..2f7653f 100644 --- a/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift @@ -9,7 +9,7 @@ import Foundation struct LiveRoomChatRawMessage: 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 } @@ -20,4 +20,5 @@ struct LiveRoomChatRawMessage: Codable { var signatureImageUrl: String? = nil let donationMessage: String? var isActiveRoulette: Bool? = nil + var isChatFrozen: Bool? = nil } diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift index 4eeb435..cab2dbf 100644 --- a/SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift @@ -12,18 +12,27 @@ struct LiveRoomJoinChatItemView: View { let chatMessage: LiveRoomJoinChat var body: some View { - HStack(spacing: 0) { - Text("'") - .appFont(size: 12) - .foregroundColor(Color.grayee) - - Text(chatMessage.nickname) - .appFont(size: 12, weight: .bold) - .foregroundColor(Color.mainYellow) - - Text("'님이 입장하셨습니다.") - .appFont(size: 12) - .foregroundColor(Color.grayee) + Group { + if let statusMessage = chatMessage.statusMessage, + !statusMessage.isEmpty { + Text(statusMessage) + .appFont(size: 12) + .foregroundColor(Color.grayee) + } else { + HStack(spacing: 0) { + Text("'") + .appFont(size: 12) + .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) .frame(width: screenSize().width - 86) diff --git a/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift b/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift index 6f7d0b5..96e30dc 100644 --- a/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift +++ b/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift @@ -28,6 +28,7 @@ struct GetRoomInfoResponse: Decodable { let menuPan: String let creatorLanguageCode: String? let isActiveRoulette: Bool + let isChatFrozen: Bool? let isPrivateRoom: Bool let password: String? } diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift index 9497c46..239ab61 100644 --- a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -185,6 +185,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { @Published var remainingNoChattingTime = 0 @Published var isActiveRoulette = false + @Published var isChatFrozen = false @Published var isShowRouletteSettings = false @@ -281,6 +282,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { var bigHeartParticleTimer: DispatchSourceTimer? var isAvailableLikeHeart = false + private var isSettingChatFreeze = false private var blockedMemberIdList = Set() @@ -308,6 +310,14 @@ final class LiveRoomViewModel: NSObject, ObservableObject { v2vAgentId != nil } + var isChatFrozenForCurrentUser: Bool { + guard let liveRoomInfo = liveRoomInfo else { + return false + } + + return isChatFrozen && liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) + } + func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) { guard isV2VJoined else { return } stopV2VTranslation(clearCaptionText: clearCaptionText) @@ -591,6 +601,9 @@ final class LiveRoomViewModel: NSObject, ObservableObject { let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) if let data = decoded.data, decoded.success { + let previousIsChatFrozen = self.isChatFrozen + let syncedIsChatFrozen = data.isChatFrozen ?? false + self.liveRoomInfo = data self.updateV2VAvailability(roomInfo: data) @@ -599,6 +612,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject { } self.isActiveRoulette = data.isActiveRoulette + self.isChatFrozen = syncedIsChatFrozen + + if syncedIsChatFrozen && !previousIsChatFrozen { + self.appendChatFreezeStatusMessage(isChatFrozen: true) + } + self.isLoading = true let rtcState = self.agora.getRtcConnectionState() @@ -670,7 +689,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject { func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) { 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.isShowPopup = true } else if chatMessage.count > 0 { @@ -1883,6 +1905,74 @@ 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 = isChatFrozen + ? I18n.LiveRoom.chatFreezeOnStatusMessage + : I18n.LiveRoom.chatFreezeOffStatusMessage + messages.append(LiveRoomJoinChat(nickname: "", statusMessage: statusMessage)) + } func showRoulette() { if let liveRoomInfo = liveRoomInfo, !isLoading { @@ -2858,6 +2948,16 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate { self.totalDonationCan += decoded.can } else if decoded.type == .TOGGLE_ROULETTE && decoded.isActiveRoulette != nil { 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 == .EDIT_ROOM_INFO || decoded.type == .SET_MANAGER { self.getRoomInfo() } else if decoded.type == .HEART_DONATION { diff --git a/SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift b/SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift index d0e11db..34c44eb 100644 --- a/SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift +++ b/SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift @@ -11,3 +11,8 @@ struct SetManagerOrSpeakerOrAudienceRequest: Encodable { let roomId: Int let memberId: Int } + +struct SetChatFreezeRequest: Encodable { + let roomId: Int + let isChatFrozen: Bool +} diff --git a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoHostView.swift b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoHostView.swift index aa80da3..6a085fc 100644 --- a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoHostView.swift +++ b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInfoHostView.swift @@ -19,6 +19,7 @@ struct LiveRoomInfoHostView: View { let isOnNotice: Bool let isOnMenuPan: Bool let isOnSignature: Bool + let isOnChatFreeze: Bool let isShowMenuPanButton: Bool let creatorId: Int @@ -40,6 +41,7 @@ struct LiveRoomInfoHostView: View { let onClickTotalHeart: () -> Void let onClickTotalDonation: () -> Void let onClickParticipants: () -> Void + let onClickToggleChatFreeze: () -> Void let onClickToggleSignature: () -> Void var body: some View { @@ -55,6 +57,18 @@ struct LiveRoomInfoHostView: View { ) { onClickQuit() } Spacer() + + LiveRoomOverlayStrokeTextToggleButton( + isOn: isOnChatFreeze, + onText: I18n.LiveRoom.chatFreezeOn, + onTextColor: Color.button, + onStrokeColor: Color.button, + offText: I18n.LiveRoom.chatFreezeOff, + offTextColor: Color.graybb, + offStrokeColor: Color.graybb, + strokeWidth: 1, + strokeCornerRadius: 5.3 + ) { onClickToggleChatFreeze() } LiveRoomOverlayStrokeTextToggleButton( isOn: isOnSignature, @@ -240,6 +254,7 @@ struct LiveRoomInfoHostView_Previews: PreviewProvider { isOnNotice: true, isOnMenuPan: false, isOnSignature: false, + isOnChatFreeze: false, isShowMenuPanButton: false, creatorId: 1, creatorNickname: "도화", @@ -271,6 +286,7 @@ struct LiveRoomInfoHostView_Previews: PreviewProvider { onClickTotalHeart: {}, onClickTotalDonation: {}, onClickParticipants: {}, + onClickToggleChatFreeze: {}, onClickToggleSignature: {} ) } diff --git a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInputChatView.swift b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInputChatView.swift index 985af31..6ba4be4 100644 --- a/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInputChatView.swift +++ b/SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomInputChatView.swift @@ -11,16 +11,27 @@ struct LiveRoomInputChatView: View { @State private var chatMessage = "" - + let isInputDisabled: Bool let sendMessage: (String) -> Bool + let onDisabledInputTap: () -> Void var body: some View { HStack(spacing: 6.7) { - ChatTextFieldView(text: $chatMessage, placeholder: "채팅을 입력하세요") { + ChatTextFieldView(text: $chatMessage, placeholder: "채팅을 입력하세요", isEnabled: !isInputDisabled) { if sendMessage(chatMessage) { chatMessage = "" } } + .allowsHitTesting(!isInputDisabled) + .overlay { + if isInputDisabled { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + onDisabledInputTap() + } + } + } .padding(.vertical, 18.3) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) @@ -28,7 +39,13 @@ struct LiveRoomInputChatView: View { Image("btn_message_send") .resizable() .frame(width: 35, height: 35) + .opacity(isInputDisabled ? 0.5 : 1) .onTapGesture { + if isInputDisabled { + onDisabledInputTap() + return + } + if sendMessage(chatMessage) { chatMessage = "" } @@ -43,12 +60,18 @@ struct LiveRoomInputChatView: View { .strokeBorder(lineWidth: 1) .foregroundColor(.gray77) ) + .onChange(of: isInputDisabled) { isDisabled in + if isDisabled { + hideKeyboard() + chatMessage = "" + } + } .padding(13.3) } } struct LiveRoomInputChatView_Previews: PreviewProvider { static var previews: some View { - LiveRoomInputChatView(sendMessage: { _ in return true }) + LiveRoomInputChatView(isInputDisabled: false, sendMessage: { _ in return true }, onDisabledInputTap: {}) } } diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index 1ce290a..c410f4f 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -31,6 +31,14 @@ struct LiveRoomViewV2: View { @State private var isShowFollowNotifyDialog: Bool = false @State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil 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) + } var body: some View { ZStack { @@ -48,6 +56,7 @@ struct LiveRoomViewV2: View { isOnNotice: viewModel.isShowNotice, isOnMenuPan: viewModel.isShowMenuPan, isOnSignature: viewModel.isSignatureOn, + isOnChatFreeze: viewModel.isChatFrozen, isShowMenuPanButton: !liveRoomInfo.menuPan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, creatorId: liveRoomInfo.creatorId, creatorNickname: liveRoomInfo.creatorNickname, @@ -88,6 +97,9 @@ struct LiveRoomViewV2: View { onClickParticipants: { viewModel.isShowProfileList = true }, + onClickToggleChatFreeze: { + viewModel.setChatFreeze(isChatFrozen: !viewModel.isChatFrozen) + }, onClickToggleSignature: { viewModel.isSignatureOn.toggle() } @@ -343,14 +355,21 @@ struct LiveRoomViewV2: View { .padding(.horizontal, 13.3) } - LiveRoomInputChatView { - viewModel.sendMessage(chatMessage: $0) { - viewModel.isShowingNewChat = false - proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) + LiveRoomInputChatView( + isInputDisabled: viewModel.isChatFrozenForCurrentUser, + sendMessage: { + viewModel.sendMessage(chatMessage: $0) { + viewModel.isShowingNewChat = false + proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) + } + + return true + }, + onDisabledInputTap: { + viewModel.errorMessage = I18n.LiveRoom.chatFreezeBlockedMessage + viewModel.isShowErrorPopup = true } - - return true - } + ) .padding(.top, isV2VCaptionVisible ? -13.3 : 0) .padding(.bottom, 10) } @@ -468,7 +487,7 @@ struct LiveRoomViewV2: View { } .sodaToast(isPresented: $viewModel.isShowErrorPopup, message: viewModel.errorMessage, autohideIn: 1.3) .cornerRadius(16.7, corners: [.topLeft, .topRight]) - .offset(y: -(keyboardHandler.keyboardHeight > 0 ? keyboardHandler.keyboardHeight : 0)) + .offset(y: -appliedKeyboardHeight) .onAppear { UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) @@ -810,8 +829,8 @@ struct LiveRoomViewV2: View { .drawingGroup(opaque: false, colorMode: .linear) } // 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동 - .offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0) - .animation(.spring(response: 0.3, dampingFraction: 0.85), value: keyboardHandler.keyboardHeight) + .offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0) + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight) } .onReceive(heartWaveTimer) { _ in guard isLongPressingHeart else { return } @@ -836,8 +855,13 @@ struct LiveRoomViewV2: View { .onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in viewModel.quitRoom() } + .onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in + if isFrozen { + hideKeyboard() + } + } .ignoresSafeArea(.keyboard) - .edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init()) + .edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init()) .sheet( isPresented: $viewModel.isShowShareView, onDismiss: { viewModel.shareMessage = "" }, diff --git a/docs/20260319_라이브룸채팅창얼리기기능구현계획.md b/docs/20260319_라이브룸채팅창얼리기기능구현계획.md new file mode 100644 index 0000000..6c1151b --- /dev/null +++ b/docs/20260319_라이브룸채팅창얼리기기능구현계획.md @@ -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(지연 입장 -> 방장 해제 -> 입력 가능 전환)는 로컬 앱 실행 환경에서 후속 확인 필요.