12 Commits

Author SHA1 Message Date
Yu Sung
d369bc11f7 fix(api): 콘텐츠 설정 PATCH 제외 API 파라미터를 제거한다 2026-03-27 22:28:21 +09:00
Yu Sung
f542191d46 fix(age-setting): 국가별 연령제한 노출 조건을 통일한다 2026-03-27 18:22:53 +09:00
Yu Sung
1d120b58bd fix(content): 성인 콘텐츠 설정 동기화와 국가별 인증 분기를 적용한다 2026-03-27 17:34:02 +09:00
Yu Sung
44daabdcae fix(live-room): 캡쳐 보호 음소거 동기화 2026-03-24 19:19:29 +09:00
Yu Sung
0844c6f4d7 fix(live-room): 라이브 상세 SNS 링크 아이콘 매핑을 신규 필드에 맞춘다 2026-03-24 13:36:10 +09:00
Yu Sung
c6a6b3c79e fix(live-room): 라이브 상세 복귀 시 DIM만 보이는 상태를 방지한다 2026-03-24 12:05:25 +09:00
Yu Sung
4f66ffb595 docs(live-room): 채팅 얼림 아이콘 이동 점검 문서를 추가한다 2026-03-20 15:59:09 +09:00
Yu Sung
91b5ed974f fix(live-room): 채팅 얼림 버튼 위치와 차단 문구를 정렬한다 2026-03-20 15:58:31 +09:00
Yu Sung
af31444f0f fix(live-room): 채팅창 얼림 버튼 위치와 안내 문구를 조정한다 2026-03-20 14:27:10 +09:00
Yu Sung
8eca5df62b feat(live-room): 라이브룸 채팅 삭제 기능 구현 2026-03-20 10:51:22 +09:00
Yu Sung
793b5dd95a fix(live-room): 채팅 금지 입력 차단 안내를 즉시 노출한다 2026-03-19 18:46:41 +09:00
Yu Sung
70003af82b feat(live-room): 채팅창 얼리기 기능을 추가한다
채팅 입력 제어와 룸 상태 동기화를 통합해 지연 입장자도 동일 상태를 적용한다.
2026-03-19 18:20:13 +09:00
69 changed files with 3091 additions and 681 deletions

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -74,6 +74,7 @@ class AppState: ObservableObject {
@Published var isShowErrorPopup = false
@Published var errorMessage = ""
@Published private var pendingContentSettingsGuideMessage: String? = nil
@Published var liveDetailSheet: LiveDetailSheetState? = nil
private func syncStepWithNavigationPath() {
@@ -180,6 +181,16 @@ class AppState: ObservableObject {
pendingCommunityCommentCreatorId = 0
pendingCommunityCommentPostId = 0
}
func setPendingContentSettingsGuideMessage(_ message: String) {
pendingContentSettingsGuideMessage = message
}
func consumePendingContentSettingsGuideMessage() -> String? {
let message = pendingContentSettingsGuideMessage
pendingContentSettingsGuideMessage = nil
return message
}
// ( -> ) UI
func softRestart() {

View File

@@ -75,6 +75,9 @@ final class AppViewModel: ObservableObject {
UserDefaults.set(data.isAuth, forKey: .auth)
UserDefaults.set(data.role.rawValue, forKey: .role)
UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification)
UserDefaults.set(data.countryCode, forKey: .countryCode)
UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(data.contentType, forKey: .contentPreference)
if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil {
AppState.shared.isShowNotificationSettingsDialog = true
}

View File

@@ -41,7 +41,14 @@ struct ChatTabView: View {
AppState.shared.setAppStep(step: .login)
return
}
if auth == false {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = {
AppState.shared
.setAppStep(step: .characterDetail(characterId: characterId))
@@ -49,8 +56,20 @@ struct ChatTabView: View {
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
moveToContentSettingsWithGuideToast()
return
}
AppState.shared.setAppStep(step: .characterDetail(characterId: characterId))
}
private func moveToContentSettingsWithGuideToast() {
AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)
AppState.shared.setAppStep(step: .contentViewSettings)
}
private func handleCharacterSelection() {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -9,7 +9,7 @@ import Foundation
import Moya
enum ContentApi {
case getAudioContentList(userId: Int, categoryId: Int, isAdultContentVisible: Bool, page: Int, size: Int, sort: ContentListViewModel.Sort)
case getAudioContentList(userId: Int, categoryId: Int, page: Int, size: Int, sort: ContentListViewModel.Sort)
case getAudioContentDetail(audioContentId: Int)
case likeContent(request: PutAudioContentLikeRequest)
case registerComment(request: RegisterAudioContentCommentRequest)
@@ -25,51 +25,51 @@ enum ContentApi {
case getNewContentUploadCreatorList
case getMainBannerList
case getMainOrderList
case getNewContentOfTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType)
case getCurationList(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getNewContentOfTheme(theme: String)
case getCurationList(page: Int, size: Int)
case donation(request: AudioContentDonationRequest)
case modifyComment(request: ModifyCommentRequest)
case getNewContentThemeList(isAdultContentVisible: Bool, contentType: ContentType)
case getNewContentAllOfTheme(isFree: Bool, theme: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getAudioContentListByCurationId(curationId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentCurationViewModel.Sort)
case getContentRanking(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sortType: String)
case getNewContentThemeList
case getNewContentAllOfTheme(isFree: Bool, theme: String, page: Int, size: Int)
case getAudioContentListByCurationId(curationId: Int, page: Int, size: Int, sort: ContentCurationViewModel.Sort)
case getContentRanking(page: Int, size: Int, sortType: String)
case getContentRankingSortType
case pinContent(contentId: Int)
case unpinContent(contentId: Int)
case getAudioContentByTheme(themeId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort)
case getAudioContentByTheme(themeId: Int, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort)
case generateUrl(contentId: Int)
case getContentMainHome(isAdultContentVisible: Bool, contentType: ContentType)
case getPopularContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType)
case getContentMainHomeContentRanking(isAdultContentVisible: Bool, contentType: ContentType, sortType: String)
case getContentMainHome
case getPopularContentByCreator(creatorId: Int)
case getContentMainHomeContentRanking(sortType: String)
case getContentMainSeries(isAdultContentVisible: Bool, contentType: ContentType)
case getRecommendSeriesListByGenre(genreId: Int, isAdultContentVisible: Bool, contentType: ContentType)
case getRecommendSeriesByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType)
case getCompletedSeries(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getContentMainSeries
case getRecommendSeriesListByGenre(genreId: Int)
case getRecommendSeriesByCreator(creatorId: Int)
case getCompletedSeries(page: Int, size: Int)
case getContentMainContent(isAdultContentVisible: Bool, contentType: ContentType)
case getContentMainNewContentOfTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType)
case getDailyContentRanking(sortType: String, isAdultContentVisible: Bool, contentType: ContentType)
case getRecommendContentByTag(tag: String, contentType: ContentType)
case getContentMainContentPopularContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType)
case getContentMainContent
case getContentMainNewContentOfTheme(theme: String)
case getDailyContentRanking(sortType: String)
case getRecommendContentByTag(tag: String)
case getContentMainContentPopularContentByCreator(creatorId: Int)
case getContentMainAlarm(isAdultContentVisible: Bool, contentType: ContentType)
case getContentMainAlarmAll(theme: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getContentMainAlarm
case getContentMainAlarmAll(theme: String, page: Int, size: Int)
case getContentMainAsmr(isAdultContentVisible: Bool, contentType: ContentType)
case getPopularAsmrContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType)
case getContentMainAsmr
case getPopularAsmrContentByCreator(creatorId: Int)
case getContentMainReplay(isAdultContentVisible: Bool, contentType: ContentType)
case getPopularReplayContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType)
case getContentMainReplay
case getPopularReplayContentByCreator(creatorId: Int)
case getContentMainFree(isAdultContentVisible: Bool, contentType: ContentType)
case getIntroduceCreatorList(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getNewFreeContentOfTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getPopularFreeContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType)
case getContentMainFree
case getIntroduceCreatorList(page: Int, size: Int)
case getNewFreeContentOfTheme(theme: String, page: Int, size: Int)
case getPopularFreeContentByCreator(creatorId: Int)
case getAllAudioContents(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, isFree: Bool?, isPointAvailableOnly: Bool?, sortType: ContentAllViewModel.Sort = .NEWEST, theme: String? = nil)
case getAudioContentActiveThemeList(isAdultContentVisible: Bool, contentType: ContentType, isFree: Bool?, isPointAvailableOnly: Bool?)
case getAllAudioContents(page: Int, size: Int, isFree: Bool?, isPointAvailableOnly: Bool?, sortType: ContentAllViewModel.Sort = .NEWEST, theme: String? = nil)
case getAudioContentActiveThemeList(isFree: Bool?, isPointAvailableOnly: Bool?)
}
extension ContentApi: TargetType {
@@ -145,7 +145,7 @@ extension ContentApi: TargetType {
case .getNewContentAllOfTheme:
return "/audio-content/main/new/all"
case .getAudioContentListByCurationId(let curationId, _, _, _, _, _):
case .getAudioContentListByCurationId(let curationId, _, _, _):
return "/audio-content/curation/\(curationId)"
case .getContentRanking:
@@ -160,7 +160,7 @@ extension ContentApi: TargetType {
case .unpinContent(let contentId):
return "/audio-content/unpin-at-the-top/\(contentId)"
case .getAudioContentByTheme(let themeId, _, _, _, _, _):
case .getAudioContentByTheme(let themeId, _, _, _):
return "/audio-content/theme/\(themeId)/content"
case .generateUrl(let contentId):
@@ -273,11 +273,10 @@ extension ContentApi: TargetType {
var task: Moya.Task {
switch self {
case .getAudioContentList(let userId, let categoryId, let isAdultContentVisible, let page, let size, let sort):
case .getAudioContentList(let userId, let categoryId, let page, let size, let sort):
let parameters = [
"creator-id": userId,
"category-id": categoryId,
"isAdultContentVisible": isAdultContentVisible,
"page": page - 1,
"size": size,
"sort-type": sort
@@ -339,11 +338,9 @@ extension ContentApi: TargetType {
case .deleteAudioContent:
return .requestPlain
case .getNewContentOfTheme(let theme, let isAdultContentVisible, let contentType):
case .getNewContentOfTheme(let theme):
let parameters = [
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"theme": theme
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
@@ -354,30 +351,21 @@ extension ContentApi: TargetType {
case .modifyComment(let request):
return .requestJSONEncodable(request)
case .getNewContentThemeList(let isAdultContentVisible, let contentType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
case .getNewContentThemeList:
return .requestPlain
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getNewContentAllOfTheme(let isFree, let theme, let isAdultContentVisible, let contentType, let page, let size):
case .getNewContentAllOfTheme(let isFree, let theme, let page, let size):
let parameters = [
"isFree": isFree,
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getAudioContentListByCurationId(_, let isAdultContentVisible, let contentType, let page, let size, let sort):
case .getAudioContentListByCurationId(_, let page, let size, let sort):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size,
"sort-type": sort
@@ -385,10 +373,8 @@ extension ContentApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentRanking(let isAdultContentVisible, let contentType, let page, let size, let sortType):
case .getContentRanking(let page, let size, let sortType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size,
"sort-type": sortType
@@ -399,10 +385,8 @@ extension ContentApi: TargetType {
case .getContentRankingSortType:
return .requestPlain
case .getCurationList(let isAdultContentVisible, let contentType, let page, let size):
case .getCurationList(let page, let size):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
@@ -412,10 +396,8 @@ extension ContentApi: TargetType {
case .pinContent, .unpinContent:
return .requestPlain
case .getAudioContentByTheme(_, let isAdultContentVisible, let contentType, let page, let size, let sort):
case .getAudioContentByTheme(_, let page, let size, let sort):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size,
"sort-type": sort
@@ -423,141 +405,109 @@ extension ContentApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainHome(let isAdultContentVisible, let contentType),
.getContentMainSeries(let isAdultContentVisible, let contentType),
.getContentMainContent(let isAdultContentVisible, let contentType),
.getContentMainAlarm(let isAdultContentVisible, let contentType),
.getContentMainAsmr(let isAdultContentVisible, let contentType),
.getContentMainReplay(let isAdultContentVisible, let contentType),
.getContentMainFree(let isAdultContentVisible, let contentType):
case .getContentMainHome,
.getContentMainSeries,
.getContentMainContent,
.getContentMainAlarm,
.getContentMainAsmr,
.getContentMainReplay,
.getContentMainFree:
return .requestPlain
case .getRecommendSeriesListByGenre(let genreId):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"genreId": genreId
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendSeriesListByGenre(let genreId, let isAdultContentVisible, let contentType):
case .getPopularContentByCreator(let creatorId):
let parameters = [
"genreId": genreId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"creatorId": creatorId
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getPopularContentByCreator(let creatorId, let isAdultContentVisible, let contentType):
case .getContentMainHomeContentRanking(let sortType):
let parameters = [
"creatorId": creatorId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"sort-type": sortType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainHomeContentRanking(let isAdultContentVisible, let contentType, let sortType):
case .getRecommendSeriesByCreator(let creatorId):
let parameters = [
"sort-type": sortType,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"creatorId": creatorId
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendSeriesByCreator(let creatorId, let isAdultContentVisible, let contentType):
case .getContentMainNewContentOfTheme(let theme):
let parameters = [
"creatorId": creatorId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"theme": theme
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDailyContentRanking(let sortType):
let parameters = [
"sort-type": sortType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendContentByTag(let tag):
let parameters = [
"tag": tag
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainContentPopularContentByCreator(let creatorId):
let parameters = [
"creatorId": creatorId
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainNewContentOfTheme(let theme, let isAdultContentVisible, let contentType):
case .getNewFreeContentOfTheme(let theme, let page, let size):
let parameters = [
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDailyContentRanking(let sortType, let isAdultContentVisible, let contentType):
let parameters = [
"sort-type": sortType,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendContentByTag(let tag, let contentType):
let parameters = [
"tag": tag,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainContentPopularContentByCreator(let creatorId, let isAdultContentVisible, let contentType):
let parameters = [
"creatorId": creatorId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getNewFreeContentOfTheme(let theme, let isAdultContentVisible, let contentType, let page, let size):
case .getContentMainAlarmAll(let theme, let page, let size):
let parameters = [
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentMainAlarmAll(let theme, let isAdultContentVisible, let contentType, let page, let size):
case .getIntroduceCreatorList(let page, let size):
let parameters = [
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getIntroduceCreatorList(let isAdultContentVisible, let contentType, let page, let size):
case .getPopularAsmrContentByCreator(let creatorId),
.getPopularReplayContentByCreator(let creatorId),
.getPopularFreeContentByCreator(let creatorId):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
"creatorId": creatorId
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getPopularAsmrContentByCreator(let creatorId, let isAdultContentVisible, let contentType),
.getPopularReplayContentByCreator(let creatorId, let isAdultContentVisible, let contentType),
.getPopularFreeContentByCreator(let creatorId, let isAdultContentVisible, let contentType):
case .getCompletedSeries(let page, let size):
let parameters = [
"creatorId": creatorId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getCompletedSeries(let isAdultContentVisible, let contentType, let page, let size):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getAllAudioContents(let isAdultContentVisible, let contentType, let page, let size, let isFree, let isPointAvailableOnly, let sortType, let theme):
case .getAllAudioContents(let page, let size, let isFree, let isPointAvailableOnly, let sortType, let theme):
var parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"sort-type": sortType,
"page": page - 1,
"size": size
@@ -577,11 +527,8 @@ extension ContentApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getAudioContentActiveThemeList(let isAdultContentVisible, let contentType, let isFree, let isPointAvailableOnly):
var parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
case .getAudioContentActiveThemeList(let isFree, let isPointAvailableOnly):
var parameters = [String : Any]()
if let isFree = isFree {
parameters["isFree"] = isFree

View File

@@ -20,7 +20,6 @@ final class ContentRepository {
.getAudioContentList(
userId: userId,
categoryId: categoryId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page,
size: size,
sort: sort)
@@ -89,19 +88,13 @@ final class ContentRepository {
func getNewContentOfTheme(theme: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getNewContentOfTheme(
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
.getNewContentOfTheme(theme: theme)
)
}
func getCurationList(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getCurationList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
@@ -117,12 +110,7 @@ final class ContentRepository {
}
func getNewContentThemeList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getNewContentThemeList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getNewContentThemeList)
}
func getNewContentAllOfTheme(isFree: Bool, theme: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
@@ -130,8 +118,6 @@ final class ContentRepository {
.getNewContentAllOfTheme(
isFree: isFree,
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
@@ -142,8 +128,6 @@ final class ContentRepository {
return api.requestPublisher(
.getAudioContentListByCurationId(
curationId: curationId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size,
sort: sort
@@ -158,8 +142,6 @@ final class ContentRepository {
func getContentRanking(page: Int, size: Int, sortType: String = "매출") -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentRanking(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size,
sortType: sortType
@@ -183,8 +165,6 @@ final class ContentRepository {
return api.requestPublisher(
.getAudioContentByTheme(
themeId: themeId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size,
sort: sort
@@ -206,8 +186,6 @@ final class ContentRepository {
) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getAllAudioContents(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size,
isFree: isFree,
@@ -221,8 +199,6 @@ final class ContentRepository {
func getAudioContentActiveThemeList(isFree: Bool, isPointAvailableOnly: Bool) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getAudioContentActiveThemeList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
isFree: isFree,
isPointAvailableOnly: isPointAvailableOnly
)

View File

@@ -24,6 +24,14 @@ struct ContentCreateView: View {
@State private var isShowSelectTimeView = false
var body: some View {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
let isAdultContentVisible = UserDefaults.isAdultContentVisible()
let shouldShowAdultSetting = isAdultContentVisible && (!isKoreanCountry || UserDefaults.bool(forKey: .auth))
BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { proxy in
ZStack {
@@ -448,34 +456,36 @@ struct ContentCreateView: View {
.padding(.top, 26.7)
.padding(.horizontal, 13.3)
VStack(spacing: 13.3) {
Text("연령 제한")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 13.3) {
SelectButtonView(title: I18n.CreateContent.allAges, isChecked: !viewModel.isAdult) {
if viewModel.isAdult {
viewModel.isAdult = false
}
}
SelectButtonView(title: I18n.CreateContent.over19, isChecked: viewModel.isAdult) {
if !viewModel.isAdult {
viewModel.isAdult = true
if shouldShowAdultSetting {
VStack(spacing: 13.3) {
Text("연령 제한")
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 13.3) {
SelectButtonView(title: I18n.CreateContent.allAges, isChecked: !viewModel.isAdult) {
if viewModel.isAdult {
viewModel.isAdult = false
}
}
SelectButtonView(title: I18n.CreateContent.over19, isChecked: viewModel.isAdult) {
if !viewModel.isAdult {
viewModel.isAdult = true
}
}
}
Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 13.3)
}
Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 13.3)
.padding(.top, 26.7)
.padding(.horizontal, 13.3)
}
.padding(.top, 26.7)
.padding(.horizontal, 13.3)
VStack(spacing: 13.3) {
Text("댓글 가능 여부")

View File

@@ -9,11 +9,11 @@ import Foundation
import Moya
enum SeriesMainApi {
case fetchHome(isAdultContentVisible: Bool, contentType: ContentType)
case getRecommendSeriesList(isAdultContentVisible: Bool, contentType: ContentType)
case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getGenreList(isAdultContentVisible: Bool, contentType: ContentType)
case getSeriesListByGenre(genreId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case fetchHome
case getRecommendSeriesList
case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, page: Int, size: Int)
case getGenreList
case getSeriesListByGenre(genreId: Int, page: Int, size: Int)
}
extension SeriesMainApi: TargetType {
@@ -39,46 +39,27 @@ extension SeriesMainApi: TargetType {
var task: Moya.Task {
switch self {
case .fetchHome(let isAdultContentVisible, let contentType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
case .fetchHome:
return .requestPlain
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendSeriesList:
return .requestPlain
case .getRecommendSeriesList(let isAdultContentVisible, let contentType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDayOfWeekSeriesList(let dayOfWeek, let isAdultContentVisible, let contentType, let page, let size):
case .getDayOfWeekSeriesList(let dayOfWeek, let page, let size):
let parameters = [
"dayOfWeek": dayOfWeek,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getGenreList(let isAdultContentVisible, let contentType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
] as [String : Any]
case .getGenreList:
return .requestPlain
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getSeriesListByGenre(let genreId, let isAdultContentVisible, let contentType, let page, let size):
case .getSeriesListByGenre(let genreId, let page, let size):
let parameters = [
"genreId": genreId,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]

View File

@@ -14,29 +14,17 @@ class SeriesMainRepository {
private let api = MoyaProvider<SeriesMainApi>()
func fetchHome() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.fetchHome(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.fetchHome)
}
func getRecommendSeriesList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getRecommendSeriesList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getRecommendSeriesList)
}
func getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getDayOfWeekSeriesList(
dayOfWeek: dayOfWeek,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
@@ -44,20 +32,13 @@ class SeriesMainRepository {
}
func getGenreList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getGenreList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
)
)
return api.requestPublisher(.getGenreList)
}
func getSeriesListByGenre(genreId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getSeriesListByGenre(
genreId: genreId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)

View File

@@ -9,10 +9,10 @@ import Foundation
import Moya
enum SeriesApi {
case getSeriesList(creatorId: Int?, isOriginal: Bool, isCompleted: Bool, sortType: SeriesListAllViewModel.SeriesSortType, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case getSeriesDetail(seriesId: Int, isAdultContentVisible: Bool)
case getSeriesContentList(seriesId: Int, isAdultContentVisible: Bool, page: Int, size: Int, sortType: SeriesListAllViewModel.SeriesSortType)
case getRecommendSeriesList(isAdultContentVisible: Bool, contentType: ContentType)
case getSeriesList(creatorId: Int?, isOriginal: Bool, isCompleted: Bool, sortType: SeriesListAllViewModel.SeriesSortType, page: Int, size: Int)
case getSeriesDetail(seriesId: Int)
case getSeriesContentList(seriesId: Int, page: Int, size: Int, sortType: SeriesListAllViewModel.SeriesSortType)
case getRecommendSeriesList
}
extension SeriesApi: TargetType {
@@ -25,10 +25,10 @@ extension SeriesApi: TargetType {
case .getSeriesList:
return "/audio-content/series"
case .getSeriesDetail(let seriesId, _):
case .getSeriesDetail(let seriesId):
return "/audio-content/series/\(seriesId)"
case .getSeriesContentList(let seriesId, _, _, _, _):
case .getSeriesContentList(let seriesId, _, _, _):
return "/audio-content/series/\(seriesId)/content"
case .getRecommendSeriesList:
@@ -45,11 +45,9 @@ extension SeriesApi: TargetType {
var task: Moya.Task {
switch self {
case .getSeriesList(let creatorId, let isOriginal, let isCompleted, let sortType, let isAdultContentVisible, let contentType, let page, let size):
case .getSeriesList(let creatorId, let isOriginal, let isCompleted, let sortType, let page, let size):
var parameters = [
"sortType": sortType,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"isOriginal": isOriginal,
"isCompleted": isCompleted,
"page": page - 1,
@@ -62,21 +60,14 @@ extension SeriesApi: TargetType {
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getSeriesDetail(_, let isAdultContentVisible):
let parameters = ["isAdultContentVisible": isAdultContentVisible] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getSeriesDetail:
return .requestPlain
case .getRecommendSeriesList(let isAdultContentVisible, let contentType):
case .getRecommendSeriesList:
return .requestPlain
case .getSeriesContentList(_, let page, let size, let sortType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getSeriesContentList(_, let isAdultContentVisible, let page, let size, let sortType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"page": page - 1,
"size": size,
"sortType": sortType

View File

@@ -20,8 +20,6 @@ class SeriesRepository {
isOriginal: isOriginal,
isCompleted: isCompleted,
sortType: sortType,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
@@ -29,19 +27,13 @@ class SeriesRepository {
}
func getSeriesDetail(seriesId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getSeriesDetail(
seriesId: seriesId,
isAdultContentVisible: UserDefaults.isAdultContentVisible()
)
)
return api.requestPublisher(.getSeriesDetail(seriesId: seriesId))
}
func getSeriesContentList(seriesId: Int, page: Int, size: Int, sortType: SeriesListAllViewModel.SeriesSortType) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getSeriesContentList(
seriesId: seriesId,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page,
size: size,
sortType: sortType
@@ -50,11 +42,6 @@ class SeriesRepository {
}
func getRecommendSeriesList() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getRecommendSeriesList(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getRecommendSeriesList)
}
}

View File

@@ -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 {

View File

@@ -12,7 +12,7 @@ enum ExplorerApi {
case getCreatorRank
case getExplorer
case searchChannel(channel: String)
case getCreatorProfile(userId: Int, isAdultContentVisible: Bool)
case getCreatorProfile(userId: Int)
case getCreatorDetail(userId: Int)
case getFollowerList(userId: Int, page: Int, size: Int)
case getCreatorProfileCheers(userId: Int, page: Int, size: Int)
@@ -40,7 +40,7 @@ extension ExplorerApi: TargetType {
case .searchChannel:
return "/explorer/search/channel"
case .getCreatorProfile(let userId, _):
case .getCreatorProfile(let userId):
return "/explorer/profile/\(userId)"
case .getCreatorDetail(let userId):
@@ -90,8 +90,8 @@ extension ExplorerApi: TargetType {
case .searchChannel(let channel):
return .requestParameters(parameters: ["channel" : channel], encoding: URLEncoding.queryString)
case .getCreatorProfile(_, let isAdultContentVisible):
let parameters = ["isAdultContentVisible": isAdultContentVisible, "timezone": TimeZone.current.identifier] as [String: Any]
case .getCreatorProfile:
let parameters = ["timezone": TimeZone.current.identifier] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getFollowerList(_, let page, let size):

View File

@@ -22,12 +22,7 @@ final class ExplorerRepository {
}
func getCreatorProfile(id: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getCreatorProfile(
userId: id,
isAdultContentVisible: UserDefaults.isAdultContentVisible()
)
)
return api.requestPublisher(.getCreatorProfile(userId: id))
}
func getCreatorDetail(id: Int) -> AnyPublisher<Response, MoyaError> {

View File

@@ -25,6 +25,7 @@ enum UserDefaultsKey: String, CaseIterable {
case notShowingEventPopupId
case isAdultContentVisible
case contentPreference
case countryCode
case isAuditionNotification
case searchChannel
case marketingPid
@@ -73,7 +74,7 @@ extension UserDefaults {
static func isAdultContentVisible() -> Bool {
let key = UserDefaultsKey.isAdultContentVisible.rawValue
return UserDefaults.standard.object(forKey: key) != nil ? bool(forKey: .isAdultContentVisible) : true
return UserDefaults.standard.object(forKey: key) != nil ? bool(forKey: .isAdultContentVisible) : false
}
static func reset() {

View File

@@ -9,11 +9,11 @@ import Foundation
import Moya
enum HomeApi {
case getHomeData(isAdultContentVisible: Bool, contentType: ContentType)
case getLatestContentByTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType)
case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek, isAdultContentVisible: Bool, contentType: ContentType)
case getRecommendContents(isAdultContentVisible: Bool, contentType: ContentType)
case getContentRankingBySort(sort: ContentRankingSortType, isAdultContentVisible: Bool, contentType: ContentType)
case getHomeData
case getLatestContentByTheme(theme: String)
case getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek)
case getRecommendContents
case getContentRankingBySort(sort: ContentRankingSortType)
}
extension HomeApi: TargetType {
@@ -46,46 +46,33 @@ extension HomeApi: TargetType {
var task: Moya.Task {
switch self {
case .getHomeData(let isAdultContentVisible, let contentType):
case .getHomeData:
let parameters = [
"timezone": TimeZone.current.identifier,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"timezone": TimeZone.current.identifier
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getLatestContentByTheme(let theme, let isAdultContentVisible, let contentType):
case .getLatestContentByTheme(let theme):
let parameters = [
"theme": theme,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"theme": theme
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getDayOfWeekSeriesList(let dayOfWeek, let isAdultContentVisible, let contentType):
case .getDayOfWeekSeriesList(let dayOfWeek):
let parameters = [
"dayOfWeek": dayOfWeek,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"dayOfWeek": dayOfWeek
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getRecommendContents(let isAdultContentVisible, let contentType):
let parameters = [
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
] as [String: Any]
case .getRecommendContents:
return .requestPlain
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .getContentRankingBySort(let sort, let isAdultContentVisible, let contentType):
case .getContentRankingBySort(let sort):
let parameters = [
"sort": sort,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"sort": sort
] as [String: Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)

View File

@@ -14,50 +14,22 @@ class HomeTabRepository {
private let api = MoyaProvider<HomeApi>()
func fetchData() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getHomeData(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getHomeData)
}
func getLatestContentByTheme(theme: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getLatestContentByTheme(
theme: theme,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getLatestContentByTheme(theme: theme))
}
func getDayOfWeekSeriesList(dayOfWeek: SeriesPublishedDaysOfWeek) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getDayOfWeekSeriesList(
dayOfWeek: dayOfWeek,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getDayOfWeekSeriesList(dayOfWeek: dayOfWeek))
}
func getRecommendContents() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getRecommendContents(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getRecommendContents)
}
func getContentRankingBySort(sort: ContentRankingSortType) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getContentRankingBySort(
sort: sort,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getContentRankingBySort(sort: sort))
}
}

View File

@@ -44,7 +44,14 @@ struct HomeTabView: View {
AppState.shared.setAppStep(step: .login)
return
}
if auth == false {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = {
AppState.shared
.setAppStep(step: .characterDetail(characterId: characterId))
@@ -52,6 +59,15 @@ struct HomeTabView: View {
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
AppState.shared.setAppStep(step: .characterDetail(characterId: characterId))
}

View File

@@ -408,6 +408,30 @@ enum I18n {
//
static var alertTitle: String { pick(ko: "알림", en: "Notice", ja: "お知らせ") }
static var adultContentAgeCheckTitle: String {
pick(
ko: "당신은 18세 이상입니까?",
en: "Are you over 18 years old?",
ja: "あなたは18歳以上ですか"
)
}
static var adultContentAgeCheckDesc: String {
pick(
ko: "해당 콘텐츠는 18세 이상만 이용이 가능합니다!",
en: "This content is available only to users aged 18 and over!",
ja: "このコンテンツは18歳以上のみ利用可能です"
)
}
static var adultContentEnableGuide: String {
pick(
ko: "민감한 콘텐츠를 보려면 콘텐츠 보기 설정에서 민감한 콘텐츠 보기를 켜주세요.",
en: "To view sensitive content, turn on Sensitive Content in Content View Settings.",
ja: "センシティブなコンテンツを表示するには、コンテンツ表示設定でセンシティブなコンテンツ表示をオンにしてください。"
)
}
//
static var logoutQuestion: String {
pick(
@@ -773,6 +797,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 +808,11 @@ 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 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 {

View File

@@ -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)
@@ -40,7 +41,7 @@ enum LiveApi {
case likeHeart(request: LiveRoomLikeHeartRequest)
case getTotalHeartCount(roomId: Int)
case heartStatus(roomId: Int)
case getLiveMain(isAdultContentVisible: Bool, contentType: ContentType)
case getLiveMain
}
extension LiveApi: TargetType {
@@ -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)
@@ -253,11 +260,9 @@ extension LiveApi: TargetType {
case .likeHeart(let request):
return .requestJSONEncodable(request)
case .getLiveMain(let isAdultContentVisible, let contentType):
case .getLiveMain:
let parameters = [
"timezone": TimeZone.current.identifier,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"timezone": TimeZone.current.identifier
] as [String: Any]
return .requestParameters(

View File

@@ -92,6 +92,10 @@ final class LiveRepository {
func setManager(roomId: Int, userId: Int) -> AnyPublisher<Response, MoyaError> {
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> {
return api.requestPublisher(.kickOut(request: LiveRoomKickOutRequest(roomId: roomId, userId: userId)))
@@ -141,11 +145,6 @@ final class LiveRepository {
}
func getLiveMain() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.getLiveMain(
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.getLiveMain)
}
}

View File

@@ -210,7 +210,6 @@ final class LiveViewModel: ObservableObject {
timezone: TimeZone.current.identifier,
dateString: nil,
status: .NOW,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page,
size: pageSize
)
@@ -268,7 +267,6 @@ final class LiveViewModel: ObservableObject {
timezone: TimeZone.current.identifier,
dateString: selectedDateString,
status: .RESERVATION,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
page: page,
size: pageSize
)

View File

@@ -169,11 +169,29 @@ struct LiveNowAllView: View {
AppState.shared.setAppStep(step: .login)
return
}
if isAdult && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
if isAdult {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
}
openLiveDetail(roomId: roomId)
}

View File

@@ -16,6 +16,7 @@ protocol LiveRoomChat {
}
struct LiveRoomNormalChat: LiveRoomChat {
let chatId: String
let userId: Int
let profileUrl: String
let nickname: String
@@ -37,6 +38,7 @@ struct LiveRoomDonationChat: LiveRoomChat {
}
struct LiveRoomRouletteDonationChat: LiveRoomChat {
let memberId: Int
let profileUrl: String
let nickname: String
let rouletteResult: String
@@ -46,6 +48,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
}
}

View File

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

View File

@@ -9,8 +9,8 @@ import Foundation
struct LiveRoomChatRawMessage: Codable {
enum LiveRoomChatRawMessageType: String, Codable {
case DONATION, SECRET_DONATION, EDIT_ROOM_INFO, SET_MANAGER, TOGGLE_ROULETTE, ROULETTE_DONATION
case HEART_DONATION, BIG_HEART_DONATION
case DONATION, SECRET_DONATION, EDIT_ROOM_INFO, SET_MANAGER, TOGGLE_ROULETTE, TOGGLE_CHAT_FREEZE, ROULETTE_DONATION
case HEART_DONATION, BIG_HEART_DONATION, NORMAL_CHAT, DELETE_CHAT, DELETE_CHAT_BY_USER
}
let type: LiveRoomChatRawMessageType
@@ -20,4 +20,7 @@ struct LiveRoomChatRawMessage: Codable {
var signatureImageUrl: String? = nil
let donationMessage: String?
var isActiveRoulette: Bool? = nil
var isChatFrozen: Bool? = nil
var chatId: String? = nil
var targetUserId: Int? = nil
}

View File

@@ -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)

View File

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

View File

@@ -33,6 +33,14 @@ struct LiveRoomCreateView: View {
}
var body: some View {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
let isAdultContentVisible = UserDefaults.isAdultContentVisible()
let shouldShowAdultSetting = isAdultContentVisible && (!isKoreanCountry || UserDefaults.bool(forKey: .auth))
BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { proxy in
ZStack {
@@ -175,7 +183,7 @@ struct LiveRoomCreateView: View {
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)
if UserDefaults.bool(forKey: .auth) {
if shouldShowAdultSetting {
AdultSettingView()
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)

View File

@@ -32,8 +32,9 @@ struct GetRoomDetailManager: Decodable {
let introduce: String
let youtubeUrl: String?
let instagramUrl: String?
let websiteUrl: String?
let blogUrl: String?
let fancimmUrl: String?
let xUrl: String?
let kakaoOpenChatUrl: String?
let profileImageUrl: String
let isCreator: Bool
}

View File

@@ -10,7 +10,7 @@ import Kingfisher
struct LiveDetailView: View {
@ObservedObject var viewModel = LiveDetailViewModel()
@StateObject private var viewModel = LiveDetailViewModel()
@State private var isExpandParticipantArea = false
@State private var isShowCancelPopup = false
@@ -152,46 +152,20 @@ struct LiveDetailView: View {
.clipShape(Circle())
VStack(spacing: 16.7) {
HStack(spacing: 6.7) {
HStack(spacing: 8) {
Text(manager.nickname)
.appFont(size: 16.7, weight: .medium)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
if let websiteUrl = manager.websiteUrl, let url = URL(string: websiteUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_website_blue")
ForEach(makeSnsItems(from: manager)) { item in
Image(item.iconName)
.resizable()
.frame(width: 33.3, height: 33.3)
.contentShape(Rectangle())
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let blogUrl = manager.blogUrl, let url = URL(string: blogUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_blog_blue")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let instagramUrl = manager.instagramUrl, let url = URL(string: instagramUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_instagram_blue")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
}
}
if let youtubeUrl = manager.youtubeUrl, let url = URL(string: youtubeUrl), UIApplication.shared.canOpenURL(url) {
Image("ic_youtube_play_blue")
.resizable()
.frame(width: 33.3, height: 33.3)
.onTapGesture {
UIApplication.shared.open(url)
openSnsLink(item.url)
}
}
}
@@ -494,4 +468,59 @@ struct LiveDetailView: View {
AppState.shared.back()
}
}
private func makeSnsItems(from manager: GetRoomDetailManager) -> [LiveDetailSnsItem] {
var items = [LiveDetailSnsItem]()
appendSnsItem(items: &items, iconName: "ic_sns_youtube", url: manager.youtubeUrl)
appendSnsItem(items: &items, iconName: "ic_sns_instagram", url: manager.instagramUrl)
appendSnsItem(items: &items, iconName: "ic_sns_x", url: manager.xUrl)
appendSnsItem(items: &items, iconName: "ic_sns_fancimm", url: manager.fancimmUrl)
appendSnsItem(items: &items, iconName: "ic_sns_kakao", url: manager.kakaoOpenChatUrl)
return items
}
private func appendSnsItem(items: inout [LiveDetailSnsItem], iconName: String, url: String?) {
guard let url else {
return
}
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
items.append(LiveDetailSnsItem(iconName: iconName, url: trimmed))
}
private func openSnsLink(_ urlString: String) {
guard let url = normalizedUrl(urlString), UIApplication.shared.canOpenURL(url) else {
return
}
UIApplication.shared.open(url)
}
private func normalizedUrl(_ urlString: String) -> URL? {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return nil
}
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
return URL(string: trimmed)
}
return URL(string: "https://\(trimmed)")
}
}
private struct LiveDetailSnsItem: Identifiable {
let iconName: String
let url: String
var id: String { "\(iconName)-\(url)" }
}

View File

@@ -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?
}

View File

@@ -11,7 +11,6 @@ struct GetRoomListRequest {
let timezone: String
let dateString: String?
let status: LiveRoomStatus
let isAdultContentVisible: Bool
let page: Int
let size: Int
}

View File

@@ -64,7 +64,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
@Published var isShowUserProfilePopup = false
@Published var changeIsAdult = false {
didSet {
if changeIsAdult && !UserDefaults.bool(forKey: .auth) {
if changeIsAdult && requiresAdultAuthenticationByCountry() {
agora.speakerMute(true)
}
}
@@ -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<Int>()
@@ -308,6 +310,18 @@ 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)
}
private var isCreator: Bool {
liveRoomInfo?.creatorId == UserDefaults.int(forKey: .userId)
}
func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) {
guard isV2VJoined else { return }
stopV2VTranslation(clearCaptionText: clearCaptionText)
@@ -509,6 +523,14 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} else {
role = .LISTENER
}
if isSpeakerMute {
agora.speakerMute(true)
}
if isMute {
agora.mute(true)
}
DEBUG_LOG("agoraConnectSuccess")
@@ -591,6 +613,9 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponse<GetRoomInfoResponse>.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 +624,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()
@@ -622,7 +653,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
getTotalDonationCan()
getTotalHeartCount()
if data.isAdult && !UserDefaults.bool(forKey: .auth) {
if data.isAdult && requiresAdultAuthenticationByCountry() {
changeIsAdult = true
}
@@ -650,44 +681,209 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
.store(in: &subscription)
}
func toggleMute() {
isMute.toggle()
agora.mute(isMute)
if isMute {
muteSpeakers.append(UInt(UserDefaults.int(forKey: .userId)))
func setMute(_ isMuted: Bool) {
isMute = isMuted
agora.mute(isMuted)
let userId = UInt(UserDefaults.int(forKey: .userId))
if isMuted {
if !muteSpeakers.contains(userId) {
muteSpeakers.append(userId)
}
} else {
if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) {
if let index = muteSpeakers.firstIndex(of: userId) {
muteSpeakers.remove(at: index)
}
}
}
private func requiresAdultAuthenticationByCountry() -> Bool {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
return isKoreanCountry && !UserDefaults.bool(forKey: .auth)
}
func toggleMute() {
setMute(!isMute)
}
func setSpeakerMute(_ isMuted: Bool) {
isSpeakerMute = isMuted
agora.speakerMute(isMuted)
}
func toggleSpeakerMute() {
isSpeakerMute.toggle()
agora.speakerMute(isSpeakerMute)
setSpeakerMute(!isSpeakerMute)
}
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 {
agora.sendMessageToGroup(textMessage: chatMessage) { _, error in
} else if !chatMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let chatId = UUID().uuidString
let chatRawMessage = LiveRoomChatRawMessage(
type: .NORMAL_CHAT,
message: chatMessage,
can: 0,
donationMessage: nil,
chatId: chatId
)
agora.sendRawMessageToGroup(rawMessage: chatRawMessage) { _, error in
if error == nil {
let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId))
let rank = self.getUserRank(userId: UserDefaults.int(forKey: .userId))
self.messages.append(LiveRoomNormalChat(userId: UserDefaults.int(forKey: .userId), profileUrl: profileUrl, nickname: nickname, rank: rank, chat: chatMessage))
self.messages.append(
LiveRoomNormalChat(
chatId: chatId,
userId: UserDefaults.int(forKey: .userId),
profileUrl: profileUrl,
nickname: nickname,
rank: rank,
chat: chatMessage
)
)
self.invalidateChat()
}
onSuccess()
}
}
}
}
func deleteChat(_ chat: LiveRoomNormalChat) {
guard isCreator else {
return
}
agora.sendRawMessageToGroup(
rawMessage: LiveRoomChatRawMessage(
type: .DELETE_CHAT,
message: chat.chat,
can: 0,
donationMessage: nil,
chatId: chat.chatId,
targetUserId: chat.userId
),
completion: { [unowned self] _, error in
if error == nil {
let previousCount = self.messages.count
self.applyDeleteChat(chatId: chat.chatId)
if previousCount == self.messages.count {
self.applyDeleteChatFallback(userId: chat.userId, message: chat.chat)
}
if previousCount != self.messages.count {
self.invalidateChat()
}
} else {
self.showDeleteSyncError()
}
},
fail: { [unowned self] in
self.showDeleteSyncError()
}
)
}
func deleteChatsByUserId(userId: Int) {
guard isCreator else {
return
}
agora.sendRawMessageToGroup(
rawMessage: LiveRoomChatRawMessage(
type: .DELETE_CHAT_BY_USER,
message: "",
can: 0,
donationMessage: nil,
targetUserId: userId
),
completion: { [unowned self] _, error in
if error == nil {
let previousCount = self.messages.count
self.applyDeleteUserChats(userId: userId)
if previousCount != self.messages.count {
self.invalidateChat()
}
} else {
self.showDeleteSyncError()
}
},
fail: { [unowned self] in
self.showDeleteSyncError()
}
)
}
private func showDeleteSyncError() {
errorMessage = I18n.Common.commonError
isShowErrorPopup = true
}
private func applyDeleteChat(chatId: String) {
messages.removeAll { chat in
guard chat.type == .CHAT,
let normalChat = chat as? LiveRoomNormalChat else {
return false
}
return normalChat.chatId == chatId
}
}
private func applyDeleteChatFallback(userId: Int, message: String) {
if let index = messages.firstIndex(where: { chat in
guard chat.type == .CHAT,
let normalChat = chat as? LiveRoomNormalChat else {
return false
}
return normalChat.userId == userId && normalChat.chat == message
}) {
messages.remove(at: index)
}
}
private func applyDeleteUserChats(userId: Int) {
messages.removeAll { chat in
switch chat.type {
case .CHAT:
guard let normalChat = chat as? LiveRoomNormalChat else {
return false
}
return normalChat.userId == userId
case .DONATION:
guard let donationChat = chat as? LiveRoomDonationChat else {
return false
}
return donationChat.memberId == userId
case .ROULETTE_DONATION:
guard let rouletteChat = chat as? LiveRoomRouletteDonationChat else {
return false
}
return rouletteChat.memberId == userId
case .JOIN:
return false
}
}
}
func donation(can: Int, message: String = "", isSecret: Bool = false) {
if isSecret && can < 10 {
@@ -1182,8 +1378,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
}
func kickOut() {
if UserDefaults.int(forKey: .userId) == liveRoomInfo?.creatorId {
repository.kickOut(roomId: AppState.shared.roomId, userId: kickOutId)
let targetUserId = kickOutId
if UserDefaults.int(forKey: .userId) == liveRoomInfo?.creatorId, targetUserId > 0 {
repository.kickOut(roomId: AppState.shared.roomId, userId: targetUserId)
.sink { result in
switch result {
case .finished:
@@ -1191,19 +1389,37 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { _ in
} receiveValue: { [unowned self] response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
let nickname = self.getUserNicknameAndProfileUrl(accountId: targetUserId).nickname
self.agora.sendMessageToPeer(peerId: String(targetUserId), rawMessage: LiveRoomRequestType.KICK_OUT.rawValue.data(using: .utf8)!) { [unowned self] _, _ in
self.popupContent = "\(nickname)님을 내보냈습니다."
self.isShowPopup = true
}
self.deleteChatsByUserId(userId: targetUserId)
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = I18n.Common.commonError
}
self.isShowErrorPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowErrorPopup = true
}
}
.store(in: &subscription)
let nickname = getUserNicknameAndProfileUrl(accountId: kickOutId).nickname
agora.sendMessageToPeer(peerId: String(kickOutId), rawMessage: LiveRoomRequestType.KICK_OUT.rawValue.data(using: .utf8)!) { [unowned self] _, error in
self.popupContent = "\(nickname)님을 내보냈습니다."
self.isShowPopup = true
}
}
if let index = muteSpeakers.firstIndex(of: UInt(kickOutId)) {
if let index = muteSpeakers.firstIndex(of: UInt(targetUserId)) {
muteSpeakers.remove(at: index)
}
@@ -1883,6 +2099,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() {
if let liveRoomInfo = liveRoomInfo, !isLoading {
@@ -1985,6 +2276,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId))
self.messages.append(
LiveRoomRouletteDonationChat(
memberId: UserDefaults.int(forKey: .userId),
profileUrl: profileUrl,
nickname: nickname,
rouletteResult: rouletteSelectedItem
@@ -2849,6 +3141,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
} else if decoded.type == .ROULETTE_DONATION {
self.messages.append(
LiveRoomRouletteDonationChat(
memberId: Int(publisher)!,
profileUrl: profileUrl,
nickname: nickname,
rouletteResult: decoded.message
@@ -2858,6 +3151,57 @@ 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 == .NORMAL_CHAT {
let memberId = Int(publisher) ?? 0
let rank = self.getUserRank(userId: memberId)
if let chatId = decoded.chatId,
!decoded.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!self.blockedMemberIdList.contains(memberId) {
self.messages.append(
LiveRoomNormalChat(
chatId: chatId,
userId: memberId,
profileUrl: profileUrl,
nickname: nickname,
rank: rank,
chat: decoded.message
)
)
}
} else if decoded.type == .DELETE_CHAT {
if Int(publisher) == self.liveRoomInfo?.creatorId {
if let chatId = decoded.chatId {
let previousCount = self.messages.count
self.applyDeleteChat(chatId: chatId)
if previousCount == self.messages.count,
let targetUserId = decoded.targetUserId,
!decoded.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.applyDeleteChatFallback(userId: targetUserId, message: decoded.message)
}
}
} else {
DEBUG_LOG("Ignore DELETE_CHAT from non-creator publisher=\(publisher)")
}
} else if decoded.type == .DELETE_CHAT_BY_USER {
if Int(publisher) == self.liveRoomInfo?.creatorId {
if let targetUserId = decoded.targetUserId {
self.applyDeleteUserChats(userId: targetUserId)
}
} else {
DEBUG_LOG("Ignore DELETE_CHAT_BY_USER from non-creator publisher=\(publisher)")
}
} else if decoded.type == .EDIT_ROOM_INFO || decoded.type == .SET_MANAGER {
self.getRoomInfo()
} else if decoded.type == .HEART_DONATION {
@@ -2870,6 +3214,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
self.addBigHeartAnimation()
}
} catch {
ERROR_LOG(error.localizedDescription)
}
}
}
@@ -2877,9 +3222,19 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
if let message = textMessage {
let memberId = Int(publisher) ?? 0
let rank = getUserRank(userId: memberId)
let chatId = UUID().uuidString
if !message.trimmingCharacters(in: .whitespaces).isEmpty && !blockedMemberIdList.contains(memberId) {
messages.append(LiveRoomNormalChat(userId: memberId, profileUrl: profileUrl, nickname: nickname, rank: rank, chat: message))
messages.append(
LiveRoomNormalChat(
chatId: chatId,
userId: memberId,
profileUrl: profileUrl,
nickname: nickname,
rank: rank,
chat: message
)
)
}
}

View File

@@ -11,3 +11,8 @@ struct SetManagerOrSpeakerOrAudienceRequest: Encodable {
let roomId: Int
let memberId: Int
}
struct SetChatFreezeRequest: Encodable {
let roomId: Int
let isChatFrozen: Bool
}

View File

@@ -11,17 +11,20 @@ struct LiveRoomRightBottomButton: View {
let imageName: String
let onClick: () -> Void
let backgroundColor: Color?
let onLongPress: (() -> Void)?
let longPressDuration: Double
init(
imageName: String,
onClick: @escaping () -> Void,
backgroundColor: Color? = nil,
onLongPress: (() -> Void)? = nil,
longPressDuration: Double = 2.0
) {
self.imageName = imageName
self.onClick = onClick
self.backgroundColor = backgroundColor
self.onLongPress = onLongPress
self.longPressDuration = longPressDuration
}
@@ -31,7 +34,15 @@ struct LiveRoomRightBottomButton: View {
.resizable()
.frame(width: 24, height: 24)
.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)
.onTapGesture { onClick() }
.onLongPressGesture(minimumDuration: longPressDuration) {

View File

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

View File

@@ -6,7 +6,6 @@
//
import SwiftUI
import Kingfisher
struct LiveRoomInfoHostView: View {
@@ -55,7 +54,7 @@ struct LiveRoomInfoHostView: View {
) { onClickQuit() }
Spacer()
LiveRoomOverlayStrokeTextToggleButton(
isOn: isOnSignature,
onText: I18n.LiveRoom.signatureOn,

View File

@@ -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: {})
}
}

View File

@@ -30,10 +30,41 @@ struct LiveRoomViewV2: View {
@State private var wavePhase: CGFloat = 0
@State private var isShowFollowNotifyDialog: Bool = false
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil
@State private var selectedChatForDelete: LiveRoomNormalChat? = nil
@State private var isShowChatDeleteDialog: Bool = false
// /
@State private var isScreenCaptureProtected: Bool = UIScreen.main.isCaptured
@State private var shouldRestoreSpeakerMuteAfterCapture: Bool = false
@State private var shouldRestoreMicMuteAfterCapture: Bool = false
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 {
ZStack {
ScreenCaptureSecureContainer {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) {
@@ -185,11 +216,19 @@ struct LiveRoomViewV2: View {
scrollObservableView
if !viewModel.changeIsAdult || UserDefaults.bool(forKey: .auth) {
LiveRoomChatView(messages: viewModel.messages) {
if $0 != UserDefaults.int(forKey: .userId) {
viewModel.getUserProfile(userId: $0)
LiveRoomChatView(
messages: viewModel.messages,
isCreator: liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId),
getUserProfile: {
if $0 != UserDefaults.int(forKey: .userId) {
viewModel.getUserProfile(userId: $0)
}
},
onLongPressChat: { chat in
selectedChatForDelete = chat
isShowChatDeleteDialog = true
}
}
)
.frame(width: screenSize().width)
.rotationEffect(Angle(degrees: 180))
.valueChanged(value: viewModel.messageChangeFlag) { _ in
@@ -210,11 +249,13 @@ struct LiveRoomViewV2: View {
VStack(alignment: .trailing, spacing: 0) {
Spacer()
LiveRoomRightBottomButton(
imageName: viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on",
onClick: { viewModel.toggleSpeakerMute() }
)
VStack(spacing: 13.3) {
LiveRoomRightBottomButton(
imageName: viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on",
onClick: { viewModel.toggleSpeakerMute() }
)
}
.padding(.bottom, 40)
.padding(.trailing, 13.3)
@@ -226,6 +267,18 @@ struct LiveRoomViewV2: View {
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(
imageName: "ic_donation_message_list",
@@ -343,14 +396,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: isChatInputDisabled,
sendMessage: {
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(.bottom, 10)
}
@@ -468,13 +528,15 @@ 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)
viewModel.initAgoraEngine()
//
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
viewModel.getMemberCan()
viewModel.initAgoraEngine()
viewModel.getRoomInfo()
viewModel.getBlockedMemberIdList()
@@ -489,6 +551,8 @@ struct LiveRoomViewV2: View {
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
NotificationCenter.default.removeObserver(self)
//
releaseForcedCaptureMute()
viewModel.stopV2VTranslationIfJoined()
viewModel.stopPeriodicPlaybackValidation()
}
@@ -570,6 +634,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 {
@@ -772,46 +854,54 @@ struct LiveRoomViewV2: View {
if isImageLoading {
LoadingView()
}
if isScreenCaptureProtected {
Color.black
.ignoresSafeArea()
.allowsHitTesting(true)
}
}
.overlay(alignment: .center) {
ZStack {
// ( )
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.frame(width: 210, height: 210)
.allowsHitTesting(false)
.opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 01 (1.0~1.5s )
WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase)
.frame(width: 210, height: 210)
.scaleEffect(viewModel.remoteHeartScale)
.allowsHitTesting(false)
//
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
// ( , #ff959a)
if !isScreenCaptureProtected {
ZStack {
ForEach(viewModel.bigHeartParticles) { p in
HeartShape()
.fill(Color(hex: "ff959a"))
.frame(width: p.size * p.scale, height: p.size * p.scale)
.rotationEffect(.degrees(p.rotation))
.offset(x: p.x, y: p.y)
.opacity(p.opacity)
.allowsHitTesting(false)
// ( )
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.frame(width: 210, height: 210)
.allowsHitTesting(false)
.opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 01 (1.0~1.5s )
WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase)
.frame(width: 210, height: 210)
.scaleEffect(viewModel.remoteHeartScale)
.allowsHitTesting(false)
//
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
// ( , #ff959a)
ZStack {
ForEach(viewModel.bigHeartParticles) { p in
HeartShape()
.fill(Color(hex: "ff959a"))
.frame(width: p.size * p.scale, height: p.size * p.scale)
.rotationEffect(.degrees(p.rotation))
.offset(x: p.x, y: p.y)
.opacity(p.opacity)
.allowsHitTesting(false)
}
}
// drawingGroup (Rect) ,
.frame(maxWidth: .infinity, maxHeight: .infinity)
.drawingGroup(opaque: false, colorMode: .linear)
}
// drawingGroup (Rect) ,
.frame(maxWidth: .infinity, maxHeight: .infinity)
.drawingGroup(opaque: false, colorMode: .linear)
//
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
}
//
.offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: keyboardHandler.keyboardHeight)
}
.onReceive(heartWaveTimer) { _ in
guard isLongPressingHeart else { return }
@@ -836,8 +926,28 @@ struct LiveRoomViewV2: View {
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
viewModel.quitRoom()
}
// ( / )
.onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
}
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
if isFrozen {
hideKeyboard()
}
}
.onChange(of: viewModel.role) { role in
guard isScreenCaptureProtected,
role == .SPEAKER,
!viewModel.isMute else {
return
}
//
viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true
}
.ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init())
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
.sheet(
isPresented: $viewModel.isShowShareView,
onDismiss: { viewModel.shareMessage = "" },
@@ -939,6 +1049,12 @@ struct LiveRoomViewV2: View {
guestFollowButtonTypeOverride = nil
}
}
.onChange(of: isShowChatDeleteDialog) { isShowing in
if isShowing {
hideKeyboard()
}
}
}
}
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
@@ -989,7 +1105,205 @@ struct LiveRoomViewV2: View {
}
}
// / SwiftUI
private struct ScreenCaptureSecureContainer<Content: View>: UIViewControllerRepresentable {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> ScreenCaptureSecureHostingController<Content> {
ScreenCaptureSecureHostingController(rootView: content)
}
func updateUIViewController(_ uiViewController: ScreenCaptureSecureHostingController<Content>, context: Context) {
uiViewController.update(rootView: content)
}
}
// SwiftUI UIKit
private final class ScreenCaptureSecureHostingController<Content: View>: UIViewController {
private let secureContainerView = ScreenCaptureSecureView()
private let hostingController: UIHostingController<Content>
init(rootView: Content) {
hostingController = UIHostingController(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = secureContainerView
}
override func viewDidLoad() {
super.viewDidLoad()
hostingController.view.backgroundColor = .clear
addChild(hostingController)
secureContainerView.embed(contentView: hostingController.view)
hostingController.didMove(toParent: self)
}
func update(rootView: Content) {
hostingController.rootView = rootView
secureContainerView.embed(contentView: hostingController.view)
}
}
// isSecureTextEntry
private final class ScreenCaptureSecureView: UIView {
private let secureTextField = ScreenCaptureSecureTextField()
private weak var secureContentView: UIView?
private let failClosedOverlayView = UIView()
private var didLogFailClosedActivation = false
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
backgroundColor = .clear
//
secureTextField.translatesAutoresizingMaskIntoConstraints = false
secureTextField.isSecureTextEntry = true
secureTextField.backgroundColor = .clear
secureTextField.textColor = .clear
secureTextField.tintColor = .clear
addSubview(secureTextField)
NSLayoutConstraint.activate([
secureTextField.topAnchor.constraint(equalTo: topAnchor),
secureTextField.leadingAnchor.constraint(equalTo: leadingAnchor),
secureTextField.trailingAnchor.constraint(equalTo: trailingAnchor),
secureTextField.bottomAnchor.constraint(equalTo: bottomAnchor)
])
failClosedOverlayView.translatesAutoresizingMaskIntoConstraints = false
failClosedOverlayView.backgroundColor = .black
failClosedOverlayView.isUserInteractionEnabled = true
failClosedOverlayView.isHidden = true
addSubview(failClosedOverlayView)
NSLayoutConstraint.activate([
failClosedOverlayView.topAnchor.constraint(equalTo: topAnchor),
failClosedOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
failClosedOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
failClosedOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
//
secureContentView = resolveSecureContentView()
updateFailClosedState(isActive: secureContentView == nil)
}
func embed(contentView: UIView) {
if secureContentView == nil {
secureContentView = resolveSecureContentView()
}
guard let secureContentView else {
contentView.removeFromSuperview()
updateFailClosedState(isActive: true)
return
}
updateFailClosedState(isActive: false)
guard contentView.superview !== secureContentView else {
return
}
//
contentView.removeFromSuperview()
contentView.translatesAutoresizingMaskIntoConstraints = false
secureContentView.addSubview(contentView)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: secureContentView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: secureContentView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: secureContentView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: secureContentView.bottomAnchor)
])
}
private func resolveSecureContentView() -> UIView? {
secureTextField.subviews.first {
let className = NSStringFromClass(type(of: $0))
return className.contains("CanvasView")
}
}
private func updateFailClosedState(isActive: Bool) {
failClosedOverlayView.isHidden = !isActive
guard isActive, !didLogFailClosedActivation else {
return
}
didLogFailClosedActivation = true
ERROR_LOG("[ScreenCaptureSecureView] secure canvas lookup failed. Activating fail-closed overlay.")
}
}
// UI
private final class ScreenCaptureSecureTextField: UITextField {
override var canBecomeFirstResponder: Bool {
false
}
override func becomeFirstResponder() -> Bool {
false
}
}
private extension LiveRoomViewV2 {
func applyScreenCaptureProtection(isCaptured: Bool) {
// UI
isScreenCaptureProtected = isCaptured
if isCaptured {
//
if !viewModel.isSpeakerMute {
viewModel.setSpeakerMute(true)
shouldRestoreSpeakerMuteAfterCapture = true
}
if !viewModel.isMute {
viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true
}
return
}
releaseForcedCaptureMute()
}
func releaseForcedCaptureMute() {
//
if shouldRestoreSpeakerMuteAfterCapture {
if viewModel.isSpeakerMute {
viewModel.setSpeakerMute(false)
}
shouldRestoreSpeakerMuteAfterCapture = false
}
if shouldRestoreMicMuteAfterCapture {
if viewModel.isMute {
viewModel.setMute(false)
}
shouldRestoreMicMuteAfterCapture = false
}
}
func guestFollowButtonType(liveRoomInfo: GetRoomInfoResponse) -> FollowButtonImageType {
if liveRoomInfo.isFollowing {
return guestFollowButtonTypeOverride ?? .following

View File

@@ -472,11 +472,29 @@ struct HomeView: View {
AppState.shared.setAppStep(step: .login)
return
}
if isAdult && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
if isAdult {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
}
openLiveDetail(roomId: roomId)
}

View File

@@ -59,6 +59,9 @@ final class HomeViewModel: ObservableObject {
UserDefaults.set(data.isAuth, forKey: .auth)
UserDefaults.set(data.role.rawValue, forKey: .role)
UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification)
UserDefaults.set(data.countryCode, forKey: .countryCode)
UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(data.contentType, forKey: .contentPreference)
if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil {
AppState.shared.isShowNotificationSettingsDialog = true
}

View File

@@ -21,6 +21,12 @@ struct MyPageView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
var body: some View {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
BaseView(isLoading: $viewModel.isLoading) {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
viewModel.isShowAuthView {
@@ -114,6 +120,7 @@ struct MyPageView: View {
CategoryButtonsView(
isShowAuthView: $viewModel.isShowAuthView,
isAuthenticated: viewModel.isAuth,
isKoreanCountry: isKoreanCountry,
showMessage: {
viewModel.errorMessage = $0
viewModel.isShowPopup = true
@@ -381,6 +388,7 @@ struct CategoryButtonsView: View {
@Binding var isShowAuthView: Bool
let isAuthenticated: Bool
let isKoreanCountry: Bool
let showMessage: (String) -> Void
let refresh: () -> Void
@@ -420,12 +428,14 @@ struct CategoryButtonsView: View {
AppState.shared.setAppStep(step: .serviceCenter)
}
CategoryButtonItem(
icon: "ic_my_auth",
title: isAuthenticated ? "인증완료" : "본인인증"
) {
if !isAuthenticated {
isShowAuthView = true
if isKoreanCountry {
CategoryButtonItem(
icon: "ic_my_auth",
title: isAuthenticated ? "인증완료" : "본인인증"
) {
if !isAuthenticated {
isShowAuthView = true
}
}
}
}

View File

@@ -9,10 +9,10 @@ import Foundation
import Moya
enum SearchApi {
case searchUnified(keyword: String, isAdultContentVisible: Bool, contentType: ContentType)
case searchCreatorList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case searchContentList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case searchSeriesList(keyword: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int)
case searchUnified(keyword: String)
case searchCreatorList(keyword: String, page: Int, size: Int)
case searchContentList(keyword: String, page: Int, size: Int)
case searchSeriesList(keyword: String, page: Int, size: Int)
}
extension SearchApi: TargetType {
@@ -42,42 +42,34 @@ extension SearchApi: TargetType {
var task: Moya.Task {
switch self {
case .searchUnified(let keyword, let isAdultContentVisible, let contentType):
case .searchUnified(let keyword):
let parameters = [
"keyword": keyword,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType
"keyword": keyword
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .searchCreatorList(let keyword, let isAdultContentVisible, let contentType, let page, let size):
case .searchCreatorList(let keyword, let page, let size):
let parameters = [
"keyword": keyword,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .searchContentList(let keyword, let isAdultContentVisible, let contentType, let page, let size):
case .searchContentList(let keyword, let page, let size):
let parameters = [
"keyword": keyword,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
case .searchSeriesList(let keyword, let isAdultContentVisible, let contentType, let page, let size):
case .searchSeriesList(let keyword, let page, let size):
let parameters = [
"keyword": keyword,
"isAdultContentVisible": isAdultContentVisible,
"contentType": contentType,
"page": page - 1,
"size": size
] as [String : Any]

View File

@@ -14,21 +14,13 @@ final class SearchRepository {
private let api = MoyaProvider<SearchApi>()
func searchUnified(keyword: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.searchUnified(
keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL
)
)
return api.requestPublisher(.searchUnified(keyword: keyword))
}
func searchCreatorList(keyword: String, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(
.searchCreatorList(
keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
@@ -39,8 +31,6 @@ final class SearchRepository {
return api.requestPublisher(
.searchContentList(
keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)
@@ -51,8 +41,6 @@ final class SearchRepository {
return api.requestPublisher(
.searchSeriesList(
keyword: keyword,
isAdultContentVisible: UserDefaults.isAdultContentVisible(),
contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL,
page: page,
size: size
)

View File

@@ -10,113 +10,138 @@ import SwiftUI
struct ContentSettingsView: View {
@StateObject var viewModel = ContentSettingsViewModel()
@ObservedObject private var appState = AppState.shared
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
DetailNavigationBar(title: "콘텐츠 보기 설정") {
if AppState.shared.isRestartApp {
AppState.shared.setAppStep(step: .splash)
} else {
AppState.shared.back()
}
}
ScrollView(.vertical) {
VStack(spacing: 0) {
HStack(spacing: 0) {
Text("민감한 콘텐츠 보기")
.appFont(size: 15, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
Image(viewModel.isAdultContentVisible ? "btn_toggle_on_big" : "btn_toggle_off_big")
.resizable()
.frame(width: 44, height: 27)
.onTapGesture {
viewModel.isAdultContentVisible.toggle()
}
BaseView(isLoading: $viewModel.isLoading) {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
DetailNavigationBar(title: "콘텐츠 보기 설정") {
if AppState.shared.isRestartApp {
AppState.shared.setAppStep(step: .splash)
} else {
AppState.shared.back()
}
.frame(height: 50)
if viewModel.isAdultContentVisible {
Rectangle()
.frame(height: 1)
.foregroundColor(Color.gray90.opacity(0.3))
}
ScrollView(.vertical) {
VStack(spacing: 0) {
HStack(spacing: 0) {
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .ALL ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .ALL
}
Text("민감한 콘텐츠 보기")
.appFont(size: 15, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
Spacer()
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .MALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
Image(viewModel.isAdultContentVisible ? "btn_toggle_on_big" : "btn_toggle_off_big")
.resizable()
.frame(width: 20, height: 20)
Text("남성향")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .MALE
}
Spacer()
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .FEMALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("여성향")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .FEMALE
}
Spacer()
.frame(width: 44, height: 27)
.onTapGesture {
viewModel.handleAdultContentToggleTap()
}
}
.frame(height: 50)
if viewModel.isAdultContentVisible {
Rectangle()
.frame(height: 1)
.foregroundColor(Color.gray90.opacity(0.3))
HStack(spacing: 0) {
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .ALL ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("전체")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .ALL
}
Spacer()
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .MALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("남성향")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .MALE
}
Spacer()
HStack(spacing: 13.3) {
Image(
viewModel.adultContentPreference == .FEMALE ?
"btn_radio_select_selected" :
"btn_radio_select_normal"
)
.resizable()
.frame(width: 20, height: 20)
Text("여성향")
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.adultContentPreference = .FEMALE
}
Spacer()
}
.frame(height: 50)
}
}
.padding(.vertical, 6.7)
.padding(.horizontal, 13.3)
.background(Color.gray22)
.cornerRadius(10)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
}
.padding(.vertical, 6.7)
.padding(.horizontal, 13.3)
.background(Color.gray22)
.cornerRadius(10)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
}
if viewModel.isShowAdultContentAgeCheckDialog {
SodaDialog(
title: I18n.Settings.adultContentAgeCheckTitle,
desc: I18n.Settings.adultContentAgeCheckDesc,
confirmButtonTitle: I18n.Common.yes,
confirmButtonAction: {
viewModel.confirmAdultContentAgeCheck()
},
cancelButtonTitle: I18n.Common.no,
cancelButtonAction: {
viewModel.cancelAdultContentAgeCheck()
}
)
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
if let pendingGuideMessage = appState.consumePendingContentSettingsGuideMessage() {
viewModel.errorMessage = pendingGuideMessage
viewModel.isShowPopup = true
}
}
}

View File

@@ -6,28 +6,183 @@
//
import Foundation
import Combine
final class ContentSettingsViewModel: ObservableObject {
@Published var isAdultContentVisible = UserDefaults.isAdultContentVisible() {
didSet {
if oldValue != isAdultContentVisible {
UserDefaults.set(isAdultContentVisible, forKey: .isAdultContentVisible)
AppState.shared.isRestartApp = true
if !isAdultContentVisible {
adultContentPreference = .ALL
UserDefaults.set(ContentType.ALL.rawValue, forKey: .contentPreference)
private let userRepository = UserRepository()
private var subscription = Set<AnyCancellable>()
private let contentPreferenceSubject = PassthroughSubject<ContentPreferenceState, Never>()
private var latestRequestToken = UUID()
private var isApplyingServerState = false
private var lastSyncedState: ContentPreferenceState
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isShowAdultContentAgeCheckDialog = false
@Published var isAdultContentVisible: Bool
@Published var adultContentPreference: ContentType
init() {
let isAdultContentVisible = UserDefaults.isAdultContentVisible()
let contentPreference = ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? .ALL
let initialState = ContentPreferenceState(
isAdultContentVisible: isAdultContentVisible,
contentType: isAdultContentVisible ? contentPreference : .ALL
)
_isAdultContentVisible = Published(initialValue: isAdultContentVisible)
_adultContentPreference = Published(initialValue: isAdultContentVisible ? contentPreference : .ALL)
lastSyncedState = initialState
bindContentPreference()
}
private func bindContentPreference() {
$isAdultContentVisible
.dropFirst()
.removeDuplicates()
.sink { [weak self] isAdultContentVisible in
guard let self = self else { return }
if self.isApplyingServerState { return }
if !isAdultContentVisible && self.adultContentPreference != .ALL {
self.adultContentPreference = .ALL
}
}
.store(in: &subscription)
Publishers.CombineLatest($isAdultContentVisible, $adultContentPreference)
.map { isAdultContentVisible, adultContentPreference in
ContentPreferenceState(
isAdultContentVisible: isAdultContentVisible,
contentType: isAdultContentVisible ? adultContentPreference : .ALL
)
}
.removeDuplicates()
.dropFirst()
.sink { [weak self] state in
guard let self = self else { return }
if self.isApplyingServerState { return }
self.applyLocalState(state)
self.contentPreferenceSubject.send(state)
}
.store(in: &subscription)
contentPreferenceSubject
.removeDuplicates()
.debounce(for: .seconds(0.3), scheduler: RunLoop.main)
.sink { [weak self] state in
self?.updateContentPreference(state: state)
}
.store(in: &subscription)
}
private func updateContentPreference(state: ContentPreferenceState) {
let request = makeUpdateContentPreferenceRequest(from: lastSyncedState, to: state)
if request.isEmpty {
return
}
let requestToken = UUID()
latestRequestToken = requestToken
isLoading = true
userRepository
.updateContentPreference(
request: request
)
.sink { [weak self] result in
guard let self = self else { return }
guard self.latestRequestToken == requestToken else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
guard self.latestRequestToken == requestToken else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponse<UpdateContentPreferenceResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
let serverState = ContentPreferenceState(
isAdultContentVisible: data.isAdultContentVisible,
contentType: data.isAdultContentVisible ? data.contentType : .ALL
)
self.applyServerState(serverState)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
private func applyLocalState(_ state: ContentPreferenceState) {
UserDefaults.set(state.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(state.contentType.rawValue, forKey: .contentPreference)
AppState.shared.isRestartApp = true
}
private func applyServerState(_ state: ContentPreferenceState) {
isApplyingServerState = true
isAdultContentVisible = state.isAdultContentVisible
adultContentPreference = state.contentType
applyLocalState(state)
lastSyncedState = state
isApplyingServerState = false
}
private func makeUpdateContentPreferenceRequest(from previousState: ContentPreferenceState, to currentState: ContentPreferenceState) -> UpdateContentPreferenceRequest {
let isAdultContentVisible = previousState.isAdultContentVisible != currentState.isAdultContentVisible
? currentState.isAdultContentVisible
: nil
let contentType = previousState.contentType != currentState.contentType
? currentState.contentType
: nil
return UpdateContentPreferenceRequest(
isAdultContentVisible: isAdultContentVisible,
contentType: contentType
)
}
func handleAdultContentToggleTap() {
if isAdultContentVisible {
isAdultContentVisible = false
} else {
isShowAdultContentAgeCheckDialog = true
}
}
@Published var adultContentPreference = ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL {
didSet {
if oldValue != adultContentPreference {
UserDefaults.set(adultContentPreference.rawValue, forKey: .contentPreference)
AppState.shared.isRestartApp = true
}
}
func confirmAdultContentAgeCheck() {
isShowAdultContentAgeCheckDialog = false
isAdultContentVisible = true
}
func cancelAdultContentAgeCheck() {
isShowAdultContentAgeCheckDialog = false
}
}
private struct ContentPreferenceState: Equatable {
let isAdultContentVisible: Bool
let contentType: ContentType
}

View File

@@ -0,0 +1,10 @@
import Foundation
struct UpdateContentPreferenceRequest: Encodable {
let isAdultContentVisible: Bool?
let contentType: ContentType?
var isEmpty: Bool {
return isAdultContentVisible == nil && contentType == nil
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
struct UpdateContentPreferenceResponse: Decodable {
let isAdultContentVisible: Bool
let contentType: ContentType
enum CodingKeys: String, CodingKey {
case isAdultContentVisible
case contentType
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isAdultContentVisible = try container.decodeIfPresent(Bool.self, forKey: .isAdultContentVisible) ?? true
let rawContentType =
try container
.decodeIfPresent(String.self, forKey: .contentType)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
contentType = ContentType(rawValue: rawContentType ?? "") ?? .ALL
}
}

View File

@@ -23,4 +23,55 @@ struct GetMemberInfoResponse: Decodable {
let followingChannelLiveNotice: Bool?
let followingChannelUploadContentNotice: Bool?
let auditionNotice: Bool?
let countryCode: String
let isAdultContentVisible: Bool
let contentType: String
enum CodingKeys: String, CodingKey {
case can
case point
case isAuth
case gender
case signupDate
case chargeCount
case role
case messageNotice
case followingChannelLiveNotice
case followingChannelUploadContentNotice
case auditionNotice
case countryCode
case isAdultContentVisible
case contentType
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
can = try container.decode(Int.self, forKey: .can)
point = try container.decode(Int.self, forKey: .point)
isAuth = try container.decode(Bool.self, forKey: .isAuth)
gender = try container.decodeIfPresent(String.self, forKey: .gender)
signupDate = try container.decode(String.self, forKey: .signupDate)
chargeCount = try container.decode(Int.self, forKey: .chargeCount)
role = try container.decode(MemberRole.self, forKey: .role)
messageNotice = try container.decodeIfPresent(Bool.self, forKey: .messageNotice)
followingChannelLiveNotice = try container.decodeIfPresent(Bool.self, forKey: .followingChannelLiveNotice)
followingChannelUploadContentNotice = try container.decodeIfPresent(Bool.self, forKey: .followingChannelUploadContentNotice)
auditionNotice = try container.decodeIfPresent(Bool.self, forKey: .auditionNotice)
countryCode =
try container
.decodeIfPresent(String.self, forKey: .countryCode)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased() ?? ""
isAdultContentVisible = try container.decodeIfPresent(Bool.self, forKey: .isAdultContentVisible) ?? true
let rawContentType =
try container
.decodeIfPresent(String.self, forKey: .contentType)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
contentType = ["ALL", "MALE", "FEMALE"].contains(rawContentType ?? "") ? (rawContentType ?? "ALL") : "ALL"
}
}

View File

@@ -16,6 +16,12 @@ struct SettingsView: View {
var body: some View {
let cardWidth = screenSize().width - 26.7
let isAuth = UserDefaults.bool(forKey: .auth)
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isNonKoreanCountry = !normalizedCountryCode.isEmpty && normalizedCountryCode != "KR"
BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { geo in
@@ -65,7 +71,7 @@ struct SettingsView: View {
AppState.shared.setAppStep(step: .languageSettings)
}
if UserDefaults.bool(forKey: .auth) {
if isAuth || isNonKoreanCountry {
Rectangle()
.frame(width: cardWidth - 26.7, height: 0.3)
.foregroundColor(Color.gray90)

View File

@@ -19,6 +19,7 @@ enum UserApi {
case searchUser(nickname: String)
case getMypage
case getMemberInfo
case updateContentPreference(request: UpdateContentPreferenceRequest)
case notification(request: UpdateNotificationSettingRequest)
case logout
case logoutAllDevice
@@ -77,6 +78,9 @@ extension UserApi: TargetType {
case .getMemberInfo:
return "/member/info"
case .updateContentPreference:
return "/member/content-preference"
case .notification:
return "/member/notification"
@@ -142,6 +146,9 @@ extension UserApi: TargetType {
case .searchUser, .getMypage, .getMemberInfo, .getMyProfile, .getChangeNicknamePrice, .checkNickname, .getBlockedMemberList, .getBlockedMemberIdList, .getMemberProfile:
return .get
case .updateContentPreference:
return .patch
case .updatePushToken, .profileUpdate, .changeNickname, .updateIdfa, .updateMarketingInfo:
return .put
@@ -182,6 +189,9 @@ extension UserApi: TargetType {
case .notification(let request):
return .requestJSONEncodable(request)
case .updateContentPreference(let request):
return .requestJSONEncodable(request)
case .signOut(let request):
return .requestJSONEncodable(request)

View File

@@ -52,6 +52,10 @@ final class UserRepository {
func getMemberInfo() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getMemberInfo)
}
func updateContentPreference(request: UpdateContentPreferenceRequest) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.updateContentPreference(request: request))
}
func getMemberCan() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getMemberInfo)

View File

@@ -0,0 +1,138 @@
# 20260319_라이브룸채팅삭제기능구현계획.md
## 개요
- 라이브룸 V2 채팅에서 **방장(크리에이터)만** 특정 채팅을 삭제할 수 있는 기능을 추가한다.
- 삭제 대상 채팅을 길게 누르면 삭제 확인 알림창을 노출하고, 삭제 시 모든 참여자의 채팅 목록에서 해당 항목을 동시에 제거한다.
- 유저 강퇴 시에는 확인 알림창 없이 즉시 해당 유저의 채팅을 일괄 삭제하고, 동일하게 모든 참여자에게 동기화한다.
- 본 문서는 구현 전 상세 설계/영향 파일/검증 기준을 고정하기 위한 계획 문서다.
## 요구사항 해석(고정)
- 채팅 삭제 권한: `liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId)` 인 경우만 허용.
- 롱프레스 대상: 일반 채팅(`LiveRoomNormalChat`) 버블.
- 삭제 확인 알림창 표시 포맷: `[닉네임]: [채팅 내용]`.
- 알림창 버튼: 취소 / 삭제.
- 삭제 전파 범위: 현재 룸의 모든 참여자 클라이언트.
- 강퇴 연계: 강퇴 확정 시 해당 유저 채팅 일괄 삭제를 즉시 실행(추가 확인 알림창 없음).
## 설계 결정
### 1) 삭제 전파 채널
- 채팅 삭제 동기화는 RTM group raw message(`LiveRoomChatRawMessage`)로 전파한다.
- 강퇴는 기존처럼 peer `KICK_OUT`을 유지하되, 별도로 group 삭제 이벤트를 추가 전송한다.
### 2) 단건 채팅 식별자
- 단건 삭제 정확도를 위해 일반 채팅 모델에 `chatId`를 추가한다.
- 일반 채팅 송신은 텍스트 publish 경로 대신 raw message(`type = NORMAL_CHAT`)로 전환하여 `chatId`를 모든 클라이언트에 동일 전달한다.
### 3) 강퇴 시 일괄 삭제 기준
- `targetUserId` 기준으로 메시지 배열에서 작성자 매칭 항목을 제거한다.
- 기본 범위: 일반 채팅(`userId`) + 후원 채팅(`memberId`) + 룰렛 후원 채팅(작성자 식별 필드 추가 시 `memberId`).
## 완료 기준 (Acceptance Criteria)
- [ ] AC1: 방장이 아닌 사용자는 채팅 롱프레스 시 삭제 액션이 노출되지 않는다.
- [ ] AC2: 방장이 일반 채팅 롱프레스 시 삭제 알림창이 노출되고, 본문이 `[닉네임]: [채팅 내용]` 포맷으로 표시된다.
- [ ] AC3: 삭제 알림창에서 `취소` 선택 시 채팅 목록 변경이 없다.
- [ ] AC4: 삭제 알림창에서 `삭제` 선택 시 해당 채팅 1건이 로컬에서 즉시 제거되고, 같은 룸 모든 사용자 화면에서도 제거된다.
- [ ] AC5: 강퇴 확정 시 대상 유저의 채팅이 확인 알림창 없이 즉시 일괄 제거된다.
- [ ] AC6: 강퇴 일괄 삭제도 같은 룸 모든 사용자 화면에 동기화된다.
- [ ] AC7: 기존 기능(채팅금지/채팅 얼림/스피커 초대/강퇴 팝업)은 회귀 없이 동작한다.
## 구현 체크리스트
### A. 채팅 모델/이벤트 확장
- [x] `SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift`
- `LiveRoomNormalChat``chatId` 필드 추가.
- 강퇴 일괄 삭제 범위를 위해 `LiveRoomRouletteDonationChat` 작성자 식별 필드(`memberId`) 추가 여부 확정 및 반영.
- [x] `SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift`
- `LiveRoomChatRawMessageType``NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 추가.
- payload 필드(`chatId`, `targetUserId`)를 optional로 추가.
### B. ViewModel 삭제 로직/동기화
- [x] `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`
- 일반 채팅 송신을 raw `NORMAL_CHAT` 이벤트 기반으로 전환하고 로컬 append 시 `chatId`를 유지.
- RTM 수신 분기에 `NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 처리 분기 추가.
- 방장 권한 가드가 포함된 `deleteChat(_:)`(단건) / `deleteChatsByUserId(_:)`(일괄) 메서드 추가.
- 강퇴 성공 경로(`kickOut()`)에 일괄 삭제 로컬 적용 + group 삭제 이벤트 브로드캐스트 추가.
- 메시지 제거 후 `invalidateChat()` 호출 일관화.
### C. UI 롱프레스/삭제 확인 알림창
- [x] `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift`
- 일반 채팅 항목 롱프레스 콜백 전달 구조 추가(`onLongPressChat`).
- 방장 여부 인자(`isCreator`)를 받아 롱프레스 활성 조건 반영.
- [x] `SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift`
- 채팅 버블 영역에 롱프레스 제스처 추가.
- 롱프레스 시 부모 콜백으로 `LiveRoomNormalChat` 전달.
- [x] `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- 삭제 대상 채팅 상태(`selectedChatForDelete`)와 삭제 알림창 표시 상태 추가.
- `LiveRoomChatView` 콜백 바인딩 및 방장 조건 연결.
- `SodaDialog`로 삭제 확인 UI 추가(취소/삭제, 본문 `[닉네임]: [채팅 내용]`).
- 삭제 확인 시 `viewModel.deleteChat(...)` 호출, 취소 시 상태 초기화.
### D. 문구/국제화
- [x] `SodaLive/Sources/I18n/I18n.swift`
- `I18n.LiveRoom`에 채팅 삭제 알림창 제목/실패 메시지(필요 시) 키 추가.
- 버튼 라벨은 기존 `I18n.Common.cancel`, `I18n.Common.delete` 재사용.
## 영향 파일(예상)
### 필수
- `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`
- `SodaLive/Sources/Live/Room/V2/Component/View/LiveRoomChatView.swift`
- `SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift`
- `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`
- `SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift`
- `SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift`
- `SodaLive/Sources/I18n/I18n.swift`
## 리스크 및 대응
- 단건 삭제 식별자 미도입 시 동일 문구 중복 채팅 오삭제 위험이 있어 `chatId` 도입을 필수로 둔다.
- 일반 채팅 송신 경로를 raw로 전환하면 구버전 클라이언트 호환성 리스크가 있으므로 배포 시점 동기화가 필요하다.
- 강퇴와 삭제 이벤트 전파 순서가 뒤섞일 수 있으므로 강퇴 성공 콜백에서 삭제 이벤트를 먼저 브로드캐스트하고 UI 종료 흐름을 유지한다.
## 검증 계획
- 정적 진단: 수정 파일 `lsp_diagnostics` 확인.
- 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 수동 QA 시나리오:
- 방장/일반유저 2계정 접속 후 일반유저 채팅 롱프레스 삭제 전파 확인.
- 일반유저 계정에서는 롱프레스 삭제 불가 확인.
- 동일 유저 강퇴 시 채팅 일괄 삭제 즉시 전파 확인(알림창 미노출).
## 검증 기록
- 2026-03-19 (계획 문서 초안)
- 무엇/왜/어떻게: 라이브룸 채팅 삭제 기능 구현 전, 현재 iOS 코드 경로(채팅 렌더링/RTM 수신/강퇴 처리)를 조사해 영향 파일과 구현 단계를 문서화했다.
- 실행 명령/도구:
- `read(LiveRoomViewV2.swift, LiveRoomViewModel.swift, LiveRoomChatView.swift, LiveRoomChatItemView.swift, LiveRoomChatRawMessage.swift, LiveApi.swift, LiveRepository.swift, I18n.swift 등)`
- `grep("KICK_OUT|sendMessage|didReceiveMessageEvent|LiveRoomChatRawMessageType|onLongPressGesture", include:"*.swift")`
- `glob("docs/*.md")`
- 결과:
- 문서 파일 생성 완료.
- 코드 구현/동작 변경은 아직 수행하지 않음.
- 2026-03-19 (채팅 삭제 기능 구현)
- 무엇/왜/어떻게: 계획 문서 기준으로 방장 전용 롱프레스 채팅 삭제, 삭제 확인 다이얼로그(`[닉네임]: [채팅 내용]`), RTM 단건/일괄 삭제 전파(`NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER`), 강퇴 시 채팅 즉시 일괄 삭제를 구현했다.
- 실행 명령/도구:
- `lsp_diagnostics(LiveRoomChatRawMessage.swift, LiveRoomChat.swift, LiveRoomViewModel.swift, LiveRoomChatView.swift, LiveRoomChatItemView.swift, LiveRoomRouletteDonationChatItemView.swift, LiveRoomViewV2.swift, I18n.swift)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `grep("chatDeleteTitle|isShowChatDeleteDialog|deleteChat|NORMAL_CHAT|DELETE_CHAT|DELETE_CHAT_BY_USER|onLongPressChat", include:"*.swift")`
- 결과:
- `SodaLive`, `SodaLive-dev` Debug 빌드 모두 `** BUILD SUCCEEDED **` 확인.
- 두 스킴 모두 test action 미구성으로 자동 테스트 실행 불가(`Scheme ... is not currently configured for the test action`).
- `lsp_diagnostics`는 단일 파일 분석 한계로 일부 false positive(`No such module`, `scope`)가 있었으나, 실제 컴파일 유효성은 `xcodebuild` 성공으로 검증했다.
- CLI 환경 제약으로 2계정 실기기/시뮬레이터 상호작용 수동 QA는 후속 필요.
- 2026-03-19 (Oracle 사후 점검 반영)
- 무엇/왜/어떻게: Oracle 리뷰에서 식별된 정합성 리스크(구버전 text 메시지 삭제 동기화, 삭제 브로드캐스트 실패 시 불일치, 공백 메시지 송수신 조건 불일치)를 보완했다.
- 실행 명령/도구:
- `task(subagent_type="oracle")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `deleteChat(_:)`, `deleteChatsByUserId(_:)`에 RTM completion/fail 처리와 공통 오류 토스트 경로를 추가했다.
- `DELETE_CHAT` 수신 시 `chatId` 미일치 상황을 대비해 `(targetUserId + message)` fallback 삭제를 추가했다.
- `sendMessage`의 공백 메시지 판별을 송수신 동일 기준(`trimmingCharacters`)으로 맞췄다.
- 보강 후 `SodaLive`, `SodaLive-dev` Debug 빌드 재성공 확인.
- 테스트는 두 스킴 모두 test action 미구성으로 자동 실행 불가.

View 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(지연 입장 -> 방장 해제 -> 입력 가능 전환)는 로컬 앱 실행 환경에서 후속 확인 필요.

View 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.`으로 자동 테스트 미구성 상태 확인.

View 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`.

View 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`.

View File

@@ -0,0 +1,188 @@
# 20260324 라이브룸 캡쳐/녹화 보안 및 오디오 차단 통합 계획
## 작업 체크리스트
- [x] `LiveRoomViewV2`의 캡쳐/녹화 감지 및 기존 음소거 제어 포인트 확인
- [x] 캡쳐/녹화 시작 시 화면 검정 오버레이 적용
- [x] 캡쳐/녹화 시작 시 음소거 강제 적용
- [x] 종료 시 화면/음소거 상태 복원 로직 점검
- [x] 진단/빌드/테스트 검증 수행 및 기록
## 수용 기준 (Acceptance Criteria)
- [x] `UIScreen.main.isCaptured == true` 상태에서 라이브룸 주요 콘텐츠 위에 검정 화면이 표시된다.
- [x] 캡쳐/녹화 상태 진입 시 스피커 출력이 음소거된다.
- [x] 내가 스피커 역할일 경우, 캡쳐/녹화 상태 진입 시 마이크도 음소거된다.
- [x] 캡쳐/녹화 상태 해제 시 사용자의 기존 음소거 상태를 유지/복원한다.
## 후속 작업 체크리스트 (화면 캡쳐 미차단)
- [x] 화면 녹화와 화면 캡쳐의 동작 차이를 iOS 시스템 제약 관점에서 확인
- [x] 코드베이스 내 캡쳐 차단/보호 패턴 유무 조사
- [x] 가능한 최소 수정안 적용 (캡쳐 시 검정 처리 보강)
- [x] LSP/빌드/테스트 및 수동 QA 결과 기록
## 후속 수용 기준 (Pass/Fail)
- [x] 원인: 화면 캡쳐 시 기존 로직이 검정 화면을 만들지 못한 이유를 코드와 플랫폼 제약으로 설명 가능
- [x] 조치: 캡쳐 시점에도 검정 처리(또는 동등한 보호)가 적용되는 코드가 반영됨
- [x] 안정성: 수정 파일 `lsp_diagnostics` 무오류
- [x] 회귀: `SodaLive`, `SodaLive-dev` Debug build 성공
## 검증 기록
### 1차 검증 (2026-03-24)
- 무엇/왜/어떻게:
- 무엇: `LiveRoomViewV2`에 캡쳐/녹화 감지, 검정 오버레이, 음소거 강제/복원 로직을 추가하고 회귀를 확인.
- 왜: 요청사항(검정 캡쳐 + 음소거) 충족과 기존 동작 안정성 보장.
- 어떻게: LSP 진단, 스킴 빌드, 테스트 액션 실행 결과를 기록하고 수동 확인 가능 범위를 점검.
- 실행 명령:
- `lsp_diagnostics(filePath: 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`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- `SodaLive` 빌드: `** BUILD SUCCEEDED **` (병렬 빌드 시 1회 `build.db locked` 발생 후 단독 재실행으로 성공)
- `SodaLive-dev` 빌드: `** BUILD SUCCEEDED **`
- `SodaLive` 테스트: `Scheme SodaLive is not currently configured for the test action.`
- `SodaLive-dev` 테스트: `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA: 현재 CLI/헤드리스 환경에서는 실제 기기/시뮬레이터에서 화면 캡쳐·녹화 시작 이벤트를 직접 조작하는 E2E 검증이 제한되어, 코드 경로(`UIScreen.capturedDidChangeNotification` 수신 시 검정 오버레이 + 음소거 적용, 해제 시 복원)까지 확인.
### 2차 검증 (2026-03-24) — 화면 캡쳐 미차단 후속 대응
- 무엇/왜/어떻게:
- 무엇: 화면 캡쳐가 그대로 저장되는 원인을 확인하고, `LiveRoomViewV2` 전체를 보안 컨테이너(`isSecureTextEntry` 기반)로 감싸 캡쳐 보호를 보강.
- 왜: `UIScreen.main.isCaptured`/`capturedDidChangeNotification`은 녹화·미러링 상태 변화에는 반응하지만, 스크린샷은 사후 알림(`userDidTakeScreenshotNotification`)만 가능해 기존 오버레이 방식으로는 캡쳐본을 검정으로 바꾸지 못하기 때문.
- 어떻게: 내부 패턴 탐색(explore) + iOS 공식 동작 조사(librarian)로 원인을 확정한 뒤, `ScreenCaptureSecureContainer``LiveRoomViewV2` body 루트에 적용.
- 실행 명령:
- `lsp_diagnostics(filePath: 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`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA: 현재 CLI 환경에서는 라이브룸 실화면에서 직접 스크린샷/녹화를 트리거하는 E2E 자동화가 불가하여, 기기/시뮬레이터에서 최종 캡쳐 결과 확인이 필요함.
- 참고: `isSecureTextEntry` 기반 전체 뷰 보호는 실무적으로 사용되는 우회 방식이며, iOS 버전별 동작 차이가 있을 수 있어 실제 단말 검증을 필수로 유지.
## 후속 작업 체크리스트 (확정 이슈/보강 통합)
- [x] `LiveRoomViewV2` 캡쳐 보호 상태에서 `.overlay` 이펙트 렌더링 차단
- [x] 캡쳐 보호 오버레이가 터치를 통과시키지 않도록 입력 차단 유지
- [x] 캡쳐 해제 시 마이크 복원 로직의 role 의존 조건 제거/보완
- [x] `ScreenCaptureSecureView.setup()` secure 컨테이너 탐색 흐름 점검
- [x] secure 컨테이너 탐색 실패 시 fail-open(`UITextField` 직접 사용) 제거 및 fail-closed 처리 적용
- [x] 관련 진단/빌드/테스트 및 수동 QA 결과 누적 기록
## 후속 수용 기준 (확정 이슈/보강 Pass/Fail)
- [x] Pass: `isScreenCaptureProtected == true`일 때 검정 보호 레이어 최상단 노출 상태에서 하트/파티클 오버레이가 렌더링되지 않는다.
- QA: `.overlay(alignment: .center)` 내부를 `if !isScreenCaptureProtected`로 가드해 보호 상태에서 오버레이 뷰 트리를 생성하지 않음을 코드 레벨로 확인.
- [x] Pass: `isScreenCaptureProtected == false`일 때 기존 하트/파티클 오버레이 동작이 유지된다.
- QA: 보호 해제 상태에서 기존 `WaterHeartView`, `bigHeartParticles` 렌더 경로가 동일하게 남아 있음을 확인.
- [x] Pass: `isScreenCaptureProtected == true`일 때 하위 UI 상호작용이 차단된다.
- QA: 오버레이에 `.allowsHitTesting(true)`가 적용되어 입력을 오버레이가 수신하는지 확인.
- [x] Pass: 캡쳐 해제 시 role 상태와 무관하게 강제 마이크 mute 복원이 누락되지 않는다.
- QA: `releaseForcedCaptureMute()`에서 `shouldRestoreMicMuteAfterCapture` 경로가 role 조건 없이 동작하는지 확인.
- [x] Pass: secure 컨테이너 탐색 실패 시 일반 계층으로 폴백하지 않고 fail-closed(검정 오버레이 유지)로 동작한다.
- QA: `secureTextField.subviews.first ?? secureTextField` 폴백 제거 및 secure canvas 탐색 실패 시 콘텐츠 미탑재/오버레이 활성화를 코드 레벨로 확인.
### 3차 검증 (2026-03-24) — 캡쳐 보호 오버레이 렌더 차단
- 무엇/왜/어떻게:
- 무엇: 캡쳐 보호 상태에서도 오버레이 하트/파티클이 노출될 수 있는 경로를 차단.
- 왜: 보호 상태의 화면 노출 가능성을 제거해 보안/정합성 리스크를 낮추기 위해.
- 어떻게: `LiveRoomViewV2.swift``.overlay` 렌더링을 `isScreenCaptureProtected`와 연동해 보호 시 비활성화하고, 정적 진단/빌드/테스트 액션을 검증.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `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`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: 두 스킴 모두 `is not currently configured for the test action`로 테스트 액션 미구성 확인
### 4차 검증 (2026-03-24) — 캡쳐 보호 확정 이슈 패치
- 무엇/왜/어떻게:
- 무엇: 캡쳐 보호 중 입력 차단과 마이크 복원 누락 이슈를 패치.
- 왜: 확정된 중간 심각도 안정성 이슈를 제거하기 위해.
- 어떻게: 코드 수정 후 LSP 진단, 스킴 빌드, 테스트 액션, 수동 QA 가능 범위를 기록.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `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`
- `Read(SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift:850~874, 1248~1271)`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(가능 범위):
- 오버레이 경로 확인: `if isScreenCaptureProtected { Color.black ... .allowsHitTesting(true) }`
- 복원 경로 확인: `if shouldRestoreMicMuteAfterCapture { if viewModel.isMute { viewModel.toggleMute() } ... }`
- 현재 CLI/헤드리스 환경에서는 실제 라이브룸 진입 후 캡쳐·녹화 이벤트를 조작하는 디바이스 E2E 검증이 제한됨.
### 5차 검증 (2026-03-24) — secure 컨테이너 폴백 fail-closed 보강
- 무엇/왜/어떻게:
- 무엇: `ScreenCaptureSecureView`에서 `secureTextField.subviews.first ?? secureTextField` 폴백을 제거하고, secure canvas 식별 실패 시 검정 fail-closed 오버레이를 유지하도록 변경.
- 왜: secure 렌더링 컨테이너 탐색 실패 시 일반 계층으로 콘텐츠가 붙어 캡쳐 보호가 무력화될 수 있는 fail-open 동작을 차단하기 위해.
- 어떻게: `CanvasView` 클래스명 기반 secure 컨테이너 탐색 + 실패 시 `ERROR_LOG` 1회 기록 및 콘텐츠 미탑재 처리.
- 실행 명령:
- `lsp_diagnostics(filePath: 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`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
## 통합 이력 (중복 문서 병합)
- 본 문서는 아래 연속 수정 문서를 하나로 통합한 기준 문서다.
- `docs/20260324_캡쳐보호초기진입음소거보완.md`
- `docs/20260324_라이브녹화오디오차단수정.md`
## 통합 작업 체크리스트 (추가)
- [x] `LiveRoomViewModel`에 idempotent 음소거 setter(`setMute`, `setSpeakerMute`) 추가
- [x] 캡쳐 보호 로직에서 toggle 호출을 setter 호출로 전환
- [x] 라이브룸 진입 시 Agora 엔진 초기화와 캡쳐 보호 적용 순서 보강
- [x] 라이브 캡쳐/녹화 시 오디오 녹음 경로 원인 분석 (코드+외부 문서)
- [x] 기존 캡쳐 보호 음소거 로직의 연결 타이밍/상태 전이 검증
- [x] 최소 수정으로 오디오 차단 누락 경로 패치
## 통합 수용 기준 (추가)
- [x] 캡쳐가 이미 활성화된 상태로 라이브룸 진입해도 원격 오디오 음소거가 누락되지 않는다.
- [x] 캡쳐 보호 진입/해제 시 마이크·스피커 음소거 상태가 토글 누적 없이 일관되게 유지/복원된다.
- [x] 영상 보호(검정/보안 컨테이너) 상태에서 라이브 오디오가 녹화에 남지 않는다.
- [x] 초기 진입/연결 완료/역할 전환 시점 모두에서 캡쳐 보호 음소거가 일관 적용된다.
- [x] 변경 파일 `lsp_diagnostics` 무오류 및 `SodaLive`/`SodaLive-dev` Debug build 성공.
## 통합 검증 기록 (추가)
### 6차 검증 (2026-03-24) — 초기 진입 캡쳐 보호 음소거 보완
- 무엇/왜/어떻게:
- 무엇: `LiveRoomViewModel`에 상태 기반 음소거 setter를 추가하고, `LiveRoomViewV2` 캡쳐 보호 경로를 toggle 호출에서 setter 호출로 전환.
- 왜: 캡쳐가 이미 켜진 상태로 화면 진입 시 Agora 엔진 초기화 타이밍 때문에 스피커 강제 음소거가 누락될 수 있는 경로를 제거하기 위해.
- 어떻게: `onAppear`에서 `initAgoraEngine()` 호출을 선행시키고, 캡쳐 보호 진입/복원 및 role 변경 경로를 idempotent setter 기반으로 정리.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift, severity: all)`
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `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`: 두 파일 모두 `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA: 현재 CLI/헤드리스 환경에서는 실제 단말에서 화면 녹화/미러링 상태로 진입하는 E2E 조작이 제한되어 코드 경로 기준으로 검증함.
### 7차 검증 (2026-03-24) — 녹화 오디오 차단 보강
- 무엇/왜/어떻게:
- 무엇: `agoraConnectSuccess` 시점에 현재 음소거 상태(`isSpeakerMute`, `isMute`)를 Agora 엔진에 재적용하고, `applyScreenCaptureProtection(isCaptured: true)`에서 role 조건을 제거해 캡쳐 상태면 마이크를 선제 음소거하도록 보강.
- 왜: 캡쳐 상태에서 선행 음소거 이후 채널 join/rejoin 기본 구독 복귀, listener→speaker 전환 시점의 짧은 오디오 누출 창을 동시에 차단하기 위해.
- 어떻게: 코드베이스 탐색(explore 2건) + 외부 문서 조사(librarian 1건)로 원인을 확정하고 최소 패치 적용.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift, severity: all)`
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `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`
- `Read(SodaLive/Sources/Live/Room/LiveRoomViewModel.swift:519~533)`
- `grep("func agoraConnectSuccess|if isSpeakerMute|if isMute", LiveRoomViewModel.swift)`
- `grep("func applyScreenCaptureProtection|if !viewModel.isMute|shouldRestoreMicMuteAfterCapture = true", LiveRoomViewV2.swift)`
- 결과:
- `lsp_diagnostics`: 두 파일 모두 `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(가능 범위):
- 캡쳐 진입 시 role과 무관한 마이크 선제 음소거 경로(`if !viewModel.isMute`) 확인.
- 연결 완료 시 `isSpeakerMute/isMute` 상태 재적용 경로 확인.
- CLI 한계로 실기기 녹화 E2E는 별도 디바이스 수동 검증 필요.

View File

@@ -0,0 +1,70 @@
# 20260324 라이브 상세 SNS 아이콘 적용
## 작업 항목
- [x] `LiveDetailView` SNS 영역 아이콘을 `CreatorDetailDialogView`에서 사용하는 SNS 아이콘 에셋으로 변경
- QA: `instagram`, `fancimm`, `x`, `youtube`, `kakaoOpenChat` URL이 유효할 때만 아이콘이 표시된다.
- [x] `GetRoomDetailManager` 신규 SNS 필드(`youtube`, `instagram`, `x`, `fancimm`, `kakaoOpenChat`)를 모두 라이브 상세 SNS 영역에 반영
- QA: 5개 SNS가 누락 없이 표시 대상에 포함된다.
- [x] SNS 아이콘 크기를 기존과 동일하게 유지
- QA: 아이콘 `frame(width: 33.3, height: 33.3)`를 유지한다.
## 검증 항목
- [x] `lsp_diagnostics`로 변경 파일 오류 확인
- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` 실행
## 검증 기록
- 일시: 2026-03-24
- 무엇: `LiveDetailView` SNS 아이콘 영역 변경 후 정적 진단
- 왜: 변경 파일의 컴파일/타입 문제 확인
- 어떻게: LSP 진단 실행
- 실행 명령: `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift)`
- 결과: `No such module 'Kingfisher'` 1건 확인(로컬 SourceKit 인덱싱 환경 이슈로 판단, 코드 문법 오류는 확인되지 않음)
- 일시: 2026-03-24
- 무엇: 앱 빌드 검증
- 왜: 수정 사항이 실제 프로젝트 빌드에 문제 없는지 확인
- 어떻게: Debug 빌드 수행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 결과: `** BUILD SUCCEEDED **`
- 일시: 2026-03-24
- 무엇: 테스트 실행 가능 여부 확인
- 왜: 변경 후 회귀 테스트 수행 시도
- 어떻게: 스킴 test 액션 실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- 결과: `Scheme SodaLive is not currently configured for the test action.` (현재 스킴 테스트 미구성)
- 일시: 2026-03-24
- 무엇: 최종 수정(들여쓰기 정리) 후 재빌드 확인
- 왜: 최종 상태 기준으로 컴파일 성공 재확인
- 어떻게: Debug 빌드 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 결과: `** BUILD SUCCEEDED **`
- 일시: 2026-03-24
- 무엇: 최종 수정 후 테스트 액션 재확인
- 왜: 최종 상태 기준 테스트 가능 여부 재확인
- 어떻게: 스킴 test 액션 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- 결과: `Scheme SodaLive is not currently configured for the test action.` (현재 스킴 테스트 미구성)
- 일시: 2026-03-24
- 무엇: SNS 매핑/크기 수동 QA(정적 확인)
- 왜: 요청한 5개 SNS 반영 및 아이콘 크기 유지 여부를 최종 확인
- 어떻게: 소스 패턴 검색으로 매핑 개수와 뷰 프레임 확인
- 실행 명령: `grep count: appendSnsItem(... "ic_sns_", ...)`, `grep content: ForEach(makeSnsItems...), .frame(width: 33.3, height: 33.3)`
- 결과: `appendSnsItem` 5건 확인, SNS 렌더링 `ForEach` + `frame(width: 33.3, height: 33.3)` 확인
- 일시: 2026-03-24
- 무엇: SNS 순서 정리 후 최종 빌드
- 왜: 최종 코드 기준 컴파일 성공 여부 확정
- 어떻게: Debug 빌드 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- 결과: `** BUILD SUCCEEDED **`
- 일시: 2026-03-24
- 무엇: SNS 순서 정리 후 최종 테스트 액션 확인
- 왜: 최종 코드 기준 테스트 실행 가능 여부 확정
- 어떻게: 스킴 test 액션 재실행
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- 결과: `Scheme SodaLive is not currently configured for the test action.` (현재 스킴 테스트 미구성)

View File

@@ -0,0 +1,37 @@
# 20260324 라이브 상세 복귀 시 DIM만 보이는 문제 수정 계획
## 작업 체크리스트
- [x] 라이브 상세 복귀 시 상세 패널 비노출 원인 분석
- [x] `LiveDetailView` 복귀 시점 표시 상태 복원 로직 구현
- [x] 원인과 수정안 문서화
- [x] 진단/빌드/테스트 검증 수행 및 기록
## 원인 분석
- `LiveDetailView`가 자체 `LiveDetailViewModel``@ObservedObject`로 생성하고 있었다.
- 이 뷰는 `ContentView`의 전역 오버레이(`appState.liveDetailSheet`)로 표시되며, 채널 보기 이동(`AppState.shared.setAppStep(step: .creatorDetail)`)이나 앱 활성/비활성 전환 시 부모 뷰가 다시 그려진다.
- 재그리기 시 `@ObservedObject` 인스턴스가 재생성되면 `showDetail`이 기본값(`false`)로 돌아가고, 하단 패널은 `offset(y: viewModel.showDetail ? 0 : proxy.size.height * 0.9)` 때문에 화면 밖으로 내려간다.
- DIM 레이어(`Color.black.opacity(0.7)`)는 `showDetail`과 무관하게 항상 렌더링되므로, 결과적으로 "상세 패널은 사라지고 DIM만 보이는" 상태가 발생한다.
## 수정안
- `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift`
- `@ObservedObject var viewModel = LiveDetailViewModel()`
-`@StateObject private var viewModel = LiveDetailViewModel()`로 변경.
- 상세 시트를 소유하는 뷰에서 ViewModel 인스턴스를 유지하도록 바꿔, 페이지 이동/복귀 및 라이프사이클 변화 시에도 `showDetail` 상태가 초기화되지 않도록 했다.
- 기존 UI 구조, 애니메이션, 닫기 로직은 변경하지 않고 상태 소유 방식만 최소 수정했다.
## 검증 기록
### 1차 검증 (2026-03-24)
- 무엇/왜/어떻게:
- 무엇: `LiveDetailView`의 ViewModel 소유 방식을 `@StateObject`로 변경.
- 왜: 복귀 시 `showDetail` 초기화로 패널이 숨겨지고 DIM만 남는 문제를 방지하기 위함.
- 어떻게: 코드 수정 후 진단/빌드/테스트 엔트리 포인트 명령으로 회귀 여부 확인.
- 실행 명령:
- `lsp_diagnostics(file: SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 결과:
- `lsp_diagnostics`: `No such module 'Kingfisher'` 1건(현재 SourceKit 환경 제약으로 인한 기존 이슈).
- `SodaLive` 빌드: `** BUILD SUCCEEDED **`
- `SodaLive` 테스트: `Scheme SodaLive is not currently configured for the test action.`
- `SodaLive-dev` 빌드: `** BUILD SUCCEEDED **`

View File

@@ -0,0 +1,178 @@
# 20260326 회원정보 응답 확장 및 콘텐츠 보기 설정 연동
## 개요
- `/member/info` 응답 확장 필드(`countryCode`, `isAdultContentVisible`, `contentType`)를 앱 상태에 반영한다.
- 설정 화면의 `콘텐츠 보기 설정` 메뉴 노출 조건을 기존 `인증 사용자` 기준에서 `인증 사용자 또는 비한국 국가 코드`까지 확장한다.
- 콘텐츠 보기 설정 변경 시 `/member/content-preference`(`PATCH`)를 호출해 서버와 클라이언트 값을 동기화한다.
- 설정값 연타 시 `debounce`로 마지막 변경값만 서버에 전송하고, 전송 중에는 로딩 다이얼로그를 노출한다.
## 요구사항 요약
- `GET /member/info`
- 추가 응답 필드
- `countryCode: String` (접속 국가 코드)
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- 메뉴 노출 규칙
- 현재 유지: `UserDefaults.bool(forKey: .auth) == true`이면 노출
- 추가: `countryCode != "KR"`인 경우에도 노출
- `PATCH /member/content-preference`
- 콘텐츠 보기 설정 변경 시 호출
- 응답 필드
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- UX
- API 호출 시 Loading Dialog 표시
- 연속 입력 시 마지막 값만 서버 전송
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `GetMemberInfoResponse``countryCode`, `isAdultContentVisible`, `contentType`를 디코딩한다.
- [x] AC2: `HomeViewModel.getMemberInfo`, `AppViewModel.getMemberInfo`에서 신규 필드가 `UserDefaults`에 저장된다.
- [x] AC3: `SettingsView``콘텐츠 보기 설정` 메뉴가 `auth == true || normalizedCountryCode != "KR"` 조건에서 노출된다.
- [x] AC4: `ContentSettingsView` 내 토글/라디오 변경 시 `/member/content-preference` `PATCH`가 호출된다.
- [x] AC5: 콘텐츠 설정 API 호출 중 `LoadingView`가 표시되고, 완료/실패 시 정상 해제된다.
- [x] AC6: 짧은 시간 내 연타(토글/라디오 연속 변경) 시 마지막 상태 1건만 전송된다.
- [x] AC7: 서버 응답 성공 시 로컬(`UserDefaults`) 상태가 최종값과 일치한다.
## 구현 체크리스트
### 1) 회원정보 응답 모델/저장 키 확장
- [x] `SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift`
- `countryCode`, `isAdultContentVisible`, `contentType` 필드 추가
- 기존 디코딩 영향(옵셔널/기본값 정책) 점검
- [x] `SodaLive/Sources/Extensions/UserDefaultsExtension.swift`
- `UserDefaultsKey`에 국가 코드 저장 키 추가(예: `countryCode`)
### 2) `/member/info` 수신 데이터 저장 경로 확장
- [x] `SodaLive/Sources/Main/Home/HomeViewModel.swift`
- `getMemberInfo()` 성공 시 신규 3개 필드 저장 로직 추가
- [x] `SodaLive/Sources/App/AppViewModel.swift`
- `getMemberInfo()` 성공 시 신규 3개 필드 저장 로직 추가
- [x] 저장 정책 정리
- 국가 코드는 대문자 정규화(`uppercased`) 후 저장
- `contentType`는 서버값 우선 저장, 미존재/비정상 값은 `ALL` fallback 검토
### 3) 설정 메뉴 노출 조건 확장
- [x] `SodaLive/Sources/Settings/SettingsView.swift`
- 기존 `if UserDefaults.bool(forKey: .auth)` 조건을
`if isAuth || isNonKoreanCountry` 형태로 확장
- `isNonKoreanCountry` 계산 시 공백/소문자 입력 대비 정규화 처리
### 4) 콘텐츠 설정 PATCH API 추가
- [x] `SodaLive/Sources/User/UserApi.swift`
- `case updateContentPreference(request: UpdateContentPreferenceRequest)` 추가
- `path`: `/member/content-preference`
- `method`: `.patch`
- `task`: `.requestJSONEncodable(request)`
- [x] `SodaLive/Sources/User/UserRepository.swift`
- `updateContentPreference(...)` 메서드 추가
- [x] 신규 DTO 추가
- [x] `SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift`
- [x] `SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift`
### 5) 콘텐츠 설정 화면 상태/전송 로직 보강
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- `@Published isLoading`, `errorMessage`, `isShowPopup` 추가
- 토글/라디오 변경 이벤트를 `Subject`로 수집
- `debounce` + `removeDuplicates`로 마지막 값만 전송
- API 성공 시 응답값 기준으로 로컬 상태 최종 확정
- API 실패 시 에러 토스트 노출 및 로딩 해제
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- `BaseView(isLoading: $viewModel.isLoading)` 적용으로 Loading Dialog 표시
- `.sodaToast(...)` 연결로 실패 메시지 표시
### 6) 회귀 영향 점검
- [x] `UserDefaults.isAdultContentVisible()` 및 기존 콘텐츠 조회 API 파라미터 경로(`HomeTabRepository`, `ContentRepository`, `SearchRepository` 등)에서 신규 저장값 반영 여부 점검
- [x] 앱 재시작 플래그(`AppState.shared.isRestartApp`)와 서버 동기화 타이밍 충돌 여부 점검
### 7) 검증 계획
- [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`
- [ ] 수동 QA
- [ ] 한국 계정(`countryCode == KR`, 미인증): 메뉴 비노출
- [ ] 비한국 계정(`countryCode != KR`, 미인증): 메뉴 노출
- [ ] 인증 계정(`isAuth == true`): 국가코드 무관 메뉴 노출
- [ ] 토글/라디오 연타 시 마지막 선택값만 서버 반영
- [ ] API 호출 중 로딩 다이얼로그 표시 및 완료 후 해제
### 8) 국가 기반 성인 접근 분기 및 18+ 확인 팝업
- [x] `SodaLive/Sources/Main/Home/HomeView.swift`
- 성인 라이브 진입 시 국가코드 분기 적용
- `KR(또는 빈값)` + 미인증: 기존 본인인증 팝업 유지
- `non-KR` + 민감 콘텐츠 OFF: `contentViewSettings` 이동 + 안내 팝업 노출
- [x] `SodaLive/Sources/Live/Now/All/LiveNowAllView.swift`
- `HomeView`와 동일한 국가 분기/가드 정책 반영
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- 민감 콘텐츠 ON 전 18+ 확인 상태 추가
- `handleAdultContentToggleTap`, `confirmAdultContentAgeCheck`, `cancelAdultContentAgeCheck` 구현
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- 스위치 탭 동작을 뷰모델 핸들러로 연결
- `SodaDialog``아니오/예` 처리 연결(예: ON + API 흐름, 아니오: OFF 유지)
- [x] `SodaLive/Sources/I18n/I18n.swift`
- `adultContentAgeCheckTitle`, `adultContentAgeCheckDesc`, `adultContentEnableGuide` 국제화 문자열 추가
- [x] `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`
- `requiresAdultAuthenticationByCountry()` 도입
- 성인 방 진입 시 인증 요구 조건을 KR 기반으로 일관화
## 영향 파일(예상)
- `SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift`
- `SodaLive/Sources/Extensions/UserDefaultsExtension.swift`
- `SodaLive/Sources/Main/Home/HomeViewModel.swift`
- `SodaLive/Sources/App/AppViewModel.swift`
- `SodaLive/Sources/Settings/SettingsView.swift`
- `SodaLive/Sources/User/UserApi.swift`
- `SodaLive/Sources/User/UserRepository.swift`
- `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- `SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift` (신규)
- `SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift` (신규)
## 리스크 및 의존성
- 백엔드가 `contentType` 문자열을 `ALL/MALE/FEMALE` 외 값으로 내려주면 디코딩 실패 가능성이 있어 방어 로직이 필요하다.
- `/member/content-preference` 응답/에러 정책이 미정이면 실패 시 롤백 기준(로컬 유지/복구) 정의가 필요하다.
- `countryCode` 미수신 시 기본 노출 정책(비노출 권장)을 명확히 정해야 메뉴 오노출을 방지할 수 있다.
## 검증 기록
- 일시: 2026-03-26
- 무엇: 회원정보 응답 확장/콘텐츠 설정 서버 동기화 작업을 위한 구현 계획 문서 작성
- 왜: 요청 범위(응답 필드 확장, 메뉴 노출 조건 변경, PATCH 연동, 로딩/디바운스)를 코드 경로 기준으로 실행 가능한 체크리스트로 정리하기 위함
- 어떻게: 기존 구현 파일(`UserApi`, `UserRepository`, `GetMemberInfoResponse`, `SettingsView`, `ContentSettingsViewModel`)과 기존 계획 문서 포맷을 조사해 항목화
- 실행 명령/도구: `read(docs/*)`, `grep("/member/info|getMemberInfo|isAdultContentVisible|contentType|debounce")`, `read(UserApi.swift, UserRepository.swift, SettingsView.swift, ContentSettingsViewModel.swift 등)`
- 결과: 구현 전용 체크리스트/완료 기준/검증 계획/리스크가 포함된 계획 문서 초안 작성 완료
- 일시: 2026-03-26
- 무엇: 회원정보 응답 확장 및 콘텐츠 보기 설정 서버 동기화 구현 완료
- 왜: `/member/info` 확장 필드 반영, 설정 메뉴 노출 조건 확장, `/member/content-preference` PATCH 연동, debounce/로딩 UX 요구사항을 충족하기 위함
- 어떻게: `GetMemberInfoResponse`/`UserDefaultsKey` 확장, `HomeViewModel`/`AppViewModel` 저장 로직 보강, `SettingsView` 노출 조건 변경, `UserApi`/`UserRepository` PATCH 추가, `ContentSettingsViewModel` Subject+debounce 동기화 및 `ContentSettingsView` 로딩/토스트 연결
- 실행 명령/도구:
- `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(수정 파일)`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트 명령은 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 타깃 미구성)
- `lsp_diagnostics`는 SourceKit 해석 범위 한계로 다수의 모듈 미해결 오류를 반환했으나, 실제 Xcode 빌드는 통과하여 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경 한계로 미실행(체크리스트 유지)
- 일시: 2026-03-27
- 무엇: 국가 기반 성인 접근 분기 및 민감 콘텐츠 ON 18+ 확인 팝업 구현 검증
- 왜: 한국/비한국 정책 분기와 민감 콘텐츠 ON 보호 UX(국제화 포함) 요구사항을 반영하고, 실제 빌드 기준으로 회귀 여부를 확인하기 위함
- 어떻게:
- `HomeView`, `LiveNowAllView` 성인 진입 가드에 국가코드 분기 추가
- `ContentSettingsViewModel`/`ContentSettingsView`에 18+ 확인 다이얼로그 플로우 추가
- `I18n.Settings`에 신규 문구 추가 및 `LiveRoomViewModel` 성인 인증 조건을 KR 기반으로 정렬
- 실행 명령/도구:
- `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(I18n.swift, ContentSettingsViewModel.swift, ContentSettingsView.swift, HomeView.swift, LiveNowAllView.swift, LiveRoomViewModel.swift)`
- 결과:
- `SodaLive` 빌드는 최초 병렬 실행 시 `build.db` lock으로 실패했으나, 단독 재실행에서 `BUILD SUCCEEDED`
- `SodaLive-dev` 빌드는 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 모듈 해석 제약으로 다수 에러를 보고했으나, 실제 `xcodebuild` 통과로 컴파일 정상 확인
- 수동 QA는 CLI 환경 한계로 미실행(체크리스트 유지)

View File

@@ -0,0 +1,67 @@
# 20260327 라이브 생성·콘텐츠 업로드 연령제한 표시 조건 수정
## 개요
- 라이브 생성 페이지와 콘텐츠 업로드 페이지의 연령제한 설정 UI 표시 조건을 정책에 맞게 통일한다.
- 표시 조건은 `isAdultContentVisible`를 필수로 하고, 접속국가가 한국(`KR` 또는 빈값)일 때만 `isAuth`를 추가로 요구한다.
## 요구사항 요약
- 대상 화면:
- 라이브 생성 (`LiveRoomCreateView`)
- 콘텐츠 업로드 (`ContentCreateView`)
- 표시 조건:
- 필수: `isAdultContentVisible == true`
- 추가: 접속국가가 한국이면 `isAuth == true`
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 라이브 생성 화면 연령제한 설정 UI가 `isAdultContentVisible == true`일 때만 표시된다.
- [x] AC2: 라이브 생성 화면에서 접속국가가 한국이면 `isAuth == true`일 때만 연령제한 설정 UI가 표시된다.
- [x] AC3: 콘텐츠 업로드 화면 연령제한 설정 UI가 동일 조건(`isAdultContentVisible` 필수 + 한국일 때 `isAuth` 추가)으로 표시된다.
- [x] AC4: 국가코드 판별은 기존 관례(공백 제거 + 대문자, 빈값은 한국 취급)를 따른다.
- [x] AC5: 수정 파일 진단, 빌드/테스트 결과와 한계를 문서 하단 검증 기록에 남긴다.
## 구현 체크리스트
- [x] 라이브 생성/콘텐츠 업로드 기존 UI 분기 코드 위치 확인
- [x] 공통 표시 조건 계산식 정의 (`isAdultContentVisible`, `isKoreanCountry`, `isAuth`)
- [x] 라이브 생성 화면 조건 적용
- [x] 콘텐츠 업로드 화면 조건 적용
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 문서 체크리스트/검증 기록 업데이트
## 검증 계획
- [x] 정적 진단: `lsp_diagnostics` (수정 파일 전체)
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 라이브 생성/콘텐츠 업로드 연령제한 UI 표시 조건 수정 계획 문서 작성
- 왜: 구현 범위와 완료 기준을 먼저 고정해 요청된 조건만 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항, 완료 기준, 검증 계획을 체크리스트로 문서화
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 라이브 생성/콘텐츠 업로드 연령제한 UI 표시 조건을 국가/설정 기반으로 수정 및 검증
- 왜: 기존 조건(인증 기반 또는 무조건 노출)을 정책(`isAdultContentVisible` 필수 + 한국일 때 `isAuth` 필수)과 일치시키기 위함
- 어떻게:
- `LiveRoomCreateView``isKoreanCountry`, `isAdultContentVisible`, `shouldShowAdultSetting` 계산 추가 후 `AdultSettingView` 표시 분기 교체
- `ContentCreateView`에 동일 계산 추가 후 연령 제한 섹션 전체를 `if shouldShowAdultSetting`으로 감싸 조건부 렌더링 적용
- 국가코드는 기존 관례대로 `trim + uppercased`, 빈값은 한국 취급
- 실행 명령/도구:
- 진단: `lsp_diagnostics(LiveRoomCreateView.swift)`, `lsp_diagnostics(ContentCreateView.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`
- 로직 수동 검증(CLI): `xcrun swift -e "import Foundation; ... shouldShow(...) ..."`
- 결과:
- 두 스킴 Debug 빌드 모두 `** BUILD SUCCEEDED **`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Kingfisher'`를 보고하지만, 실제 xcodebuild 통과로 컴파일 정상 확인
- CLI 로직 검증 출력:
- `KR + visible=true + auth=true => true`
- `KR + visible=true + auth=false => false`
- `US + visible=true + auth=false => true`
- `US + visible=false + auth=true => false`

View File

@@ -0,0 +1,70 @@
# 20260327 마이페이지 본인인증 아이템 국가 조건 적용
## 개요
- `MyPageView`의 카테고리 버튼 중 `본인인증/인증완료` 아이템을 접속국가가 한국(`KR`)인 경우에만 노출되도록 변경한다.
- 기존 인증 플로우(Bootpay 호출, 인증 상태 문구)는 한국 사용자에서만 기존대로 유지한다.
## 요구사항 요약
- 대상 파일: `SodaLive/Sources/MyPage/MyPageView.swift`
- 변경 조건:
- 접속국가 코드가 `KR`(정규화 기준 적용)일 때만 `본인인증/인증완료` 아이템 표시
- 국가코드 미수신(빈값) 시 기존 저장소 관례에 맞춰 한국 정책(`KR`)으로 취급
- `KR`이 아니면 해당 아이템 미표시
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `MyPageView`에서 접속국가 코드 정규화(`trim + uppercased`)가 적용된다.
- [x] AC2: `CategoryButtonsView``본인인증/인증완료` 아이템이 한국 사용자에게만 노출된다.
- [x] AC3: 한국 사용자의 기존 인증 플로우(`isShowAuthView = true`)가 유지된다.
- [x] AC4: 빌드/진단 검증 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] `MyPageView`에서 국가코드 기반 불리언(`isKoreanCountry`) 계산 로직 추가
- [x] `CategoryButtonsView`에 국가코드 조건 전달 파라미터 추가
- [x] 카테고리 그리드의 `본인인증/인증완료` 아이템 KR 조건부 렌더링 적용
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 명령 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단: `lsp_diagnostics("SodaLive/Sources/MyPage/MyPageView.swift")`
- [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`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 마이페이지 본인인증 아이템 국가 조건 적용 작업 계획 문서 작성
- 왜: 구현 전 변경 범위와 검증 절차를 체크리스트 기반으로 고정하기 위함
- 어떻게: 기존 `docs` 문서 포맷을 기준으로 요구사항/완료기준/검증계획을 정리
- 실행 명령/도구: `read(docs/)`, `apply_patch(문서 생성)`
- 결과: 구현용 계획 문서 초안 생성 완료
- 일시: 2026-03-27
- 무엇: 마이페이지 `본인인증/인증완료` 아이템 KR 조건부 노출 구현 및 검증
- 왜: 비한국 접속국가에서 해당 아이템이 노출되지 않도록 정책을 적용하기 위함
- 어떻게:
- `MyPageView`에서 `countryCode``trim + uppercased`로 정규화하고 `isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"` 계산
- `CategoryButtonsView``isKoreanCountry` 전달 파라미터를 추가하고, `ic_my_auth` 아이템을 `if isKoreanCountry`로 감싸 조건부 렌더링
- 사용자 요청 search-mode에 맞춰 explore/librarian 병렬 탐색 + Grep/ast-grep 직접 탐색 결과를 교차 검증
- 실행 명령/도구:
- Background agents:
- `task(subagent_type="explore", description="Find KR gating patterns")`
- `task(subagent_type="explore", description="Trace MyPage auth item")`
- `task(subagent_type="librarian", description="Find SwiftUI conditional item patterns")`
- `task(subagent_type="librarian", description="Find locale/country code handling examples")`
- Direct search:
- `grep("본인인증|인증완료|ic_my_auth", MyPageView.swift)`
- `ast_grep_search("CategoryButtonItem(icon: \"ic_my_auth\", title: $TITLE) { $$$ }", lang: "swift")`
- `rg` 시도(`command not found: rg`)로 환경 미설치 확인
- 검증:
- `lsp_diagnostics("SodaLive/Sources/MyPage/MyPageView.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`
- 결과:
- `SodaLive` / `SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고했으나, 실제 `xcodebuild` 통과로 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경 한계로 미실행(실기기/시뮬레이터에서 KR/non-KR 노출 확인 필요)

View File

@@ -0,0 +1,68 @@
# 20260327 제외 API 콘텐츠 설정 파라미터 제거
## 개요
- `PATCH /member/content-preference`를 제외한 모든 API 요청에서 `isAdultContentVisible`, `contentType` 파라미터를 제거한다.
- 콘텐츠 설정 동기화 API(`PATCH /member/content-preference`)의 요청/응답 구조와 호출 흐름은 유지한다.
## 요구사항 요약
- 유지 대상 API: `PATCH /member/content-preference`
- 제거 대상: 유지 대상 API를 제외한 나머지 API 요청 파라미터의 `isAdultContentVisible`, `contentType`
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `PATCH /member/content-preference` 외 API 정의에서 `isAdultContentVisible`/`contentType` 요청 파라미터가 제거된다.
- [x] AC2: 제거에 따라 연쇄되는 Repository/Request 모델 시그니처가 정합성 있게 정리된다.
- [x] AC3: `PATCH /member/content-preference` 요청/응답 필드(`isAdultContentVisible`, `contentType`)는 유지된다.
- [x] AC4: 정적 진단/빌드/수동 QA(검색 검증) 결과가 통과 또는 사유와 함께 기록된다.
## 구현 체크리스트
- [x] API 타깃(`HomeApi`, `LiveApi`, `SearchApi`, `ContentApi`, `SeriesApi`, `SeriesMainApi`, `ExplorerApi`) 파라미터 제거
- [x] 연관 Repository 메서드 시그니처 및 호출부 인자 정리
- [x] `PATCH /member/content-preference` 체인(`UserApi`, `UpdateContentPreferenceRequest/Response`, `ContentSettingsViewModel`) 유지 확인
- [x] 진단/빌드/수동 QA 실행
- [x] 검증 기록 문서화
## 검증 계획
- [x] 정적 진단:
- `lsp_diagnostics` on modified Swift files
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- [x] 수동 QA:
- `grep` 기반으로 `/member/content-preference` PATCH 외 API 요청 파라미터 잔존 여부 확인
## 검증 기록
- 일시: 2026-03-27
- 무엇: 제외 API 콘텐츠 설정 파라미터 제거 작업 계획 문서 작성
- 왜: 변경 범위와 완료 기준을 선행 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 완료 기준/체크리스트/검증 계획 수립
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: `PATCH /member/content-preference` 제외 API의 `isAdultContentVisible`/`contentType` 요청 파라미터 제거
- 왜: 콘텐츠 설정 PATCH API를 제외한 다른 API에서 두 파라미터를 전송하지 않도록 하기 위함
- 어떻게:
- API 타깃(`HomeApi`, `LiveApi`, `SearchApi`, `ContentApi`, `SeriesApi`, `SeriesMainApi`, `ExplorerApi`) case 시그니처와 `task` 파라미터 딕셔너리에서 두 필드를 제거
- 연관 Repository 호출부에서 `UserDefaults` 기반 인자 전달 제거
- `GetRoomListRequest``isAdultContentVisible` 필드 및 `LiveViewModel` 생성 인자 제거
- `PATCH /member/content-preference` 체인(`UserApi.updateContentPreference`, `UpdateContentPreferenceRequest/Response`) 유지 검증
- 실행 명령/도구:
- 정적 진단: `lsp_diagnostics` (수정 파일 전체)
- 빌드:
- `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`
- 수동 QA(검색 검증):
- `grep("isAdultContentVisible\\s*:", path="SodaLive/Sources")`
- `grep("contentType\\s*:", path="SodaLive/Sources")`
- `grep("updateContentPreference|member/content-preference", path="SodaLive/Sources/User/UserApi.swift")`
- 결과:
- 빌드: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트: 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`: SourceKit 환경에서 외부 모듈(`Moya`, `CombineMoya`) 해석 한계로 모듈 미해결 에러가 반환됨
- 수동 QA: `isAdultContentVisible`/`contentType`는 설정 동기화 체인과 응답 모델/화면 상태에만 남고, `/member/content-preference` PATCH 외 API 요청 파라미터에서는 제거됨

View File

@@ -0,0 +1,94 @@
# 20260327 캐릭터 리스트 콘텐츠 설정 이동 안내 표시 개선
## 개요
- 채팅 캐릭터 리스트에서 `isAdultContentVisible == false`로 인해 콘텐츠 보기 설정으로 이동할 때 안내 토스트/팝업이 사용자에게 보이지 않는 문제를 수정한다.
- 이동 시점과 안내 표시 시점을 조정해 사용자가 안내 문구를 실제로 확인할 수 있도록 한다.
## 요구사항 요약
- 대상 흐름: 캐릭터 리스트 상세 진입 가드에서 non-KR + 민감 콘텐츠 OFF 분기
- 문제: 현재는 현재 화면에 토스트를 띄우고 곧바로 화면 전환되어 안내가 체감되지 않음
- 목표: 콘텐츠 보기 설정 화면 전환 후에도 안내 메시지가 사용자에게 명확히 보이도록 처리
## 완료 기준 (Acceptance Criteria)
- [x] AC1: non-KR + 민감 콘텐츠 OFF 분기에서 콘텐츠 설정으로 이동 동작은 유지된다.
- [x] AC2: 안내 메시지가 실제로 보이는 시점으로 표시 로직이 조정된다.
- [x] AC3: KR 인증 분기/기존 인증 플로우에는 영향이 없다.
- [x] AC4: 관련 화면에서 빌드/진단 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] 팝업 렌더링 위치/생명주기 확인
- [x] 기존 이동 + 메시지 설정 순서의 문제 원인 확정
- [x] 최소 수정으로 안내 메시지 표시 시점 조정
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 캐릭터 리스트 콘텐츠 설정 이동 시 안내 표시 개선 작업 계획 문서 작성
- 왜: 구현 전 변경 범위/완료 기준/검증 절차를 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항/체크리스트/검증계획을 정리
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 채팅 캐릭터 리스트의 non-KR + 민감 콘텐츠 OFF 분기에서 콘텐츠 설정 이동 안내 표시 시점 조정
- 왜: 토스트를 먼저 띄우고 즉시 화면 전환하면 사용자가 안내 메시지를 보기 어려워 UX가 손실되기 때문
- 어떻게:
- analyze-mode 요구에 맞춰 병렬 탐색 수행
- explore: `Trace error popup lifecycle`, `Find message-after-navigation patterns`
- direct: `grep/ast-grep/lsp_symbols``AppState.errorMessage`, `isShowErrorPopup`, `.contentViewSettings`, `sodaToast` 렌더링 위치 확인
- `ChatTabView`에서 non-KR 분기를 `if !isKoreanCountry && !UserDefaults.isAdultContentVisible()`로 유지
- 기존의 “토스트 세팅 후 즉시 이동” 대신 `moveToContentSettingsWithGuideToast()`
- 먼저 `.contentViewSettings`로 이동
- `DispatchQueue.main.asyncAfter(0.2)` 후 안내 토스트 표시
- scope 최소화를 위해 요청 대상인 채팅 캐릭터 리스트 경로(`ChatTabView`)만 수정
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/Chat/ChatTabView.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`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고했으나, 실제 빌드 통과로 컴파일 정상 확인
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 non-KR + 민감 콘텐츠 OFF 시 콘텐츠 설정 화면에서 안내 토스트 노출 확인 필요)
- 일시: 2026-03-27
- 무엇: 재현 보고(토스트 미노출) 기반 2차 수정 — 콘텐츠 설정 화면에서 안내 토스트를 직접 소비하도록 변경
- 왜: 기존 방식은 전환 타이밍/전역 토스트 상태 의존으로 인해 사용자 환경에서 안내가 보이지 않는 케이스가 재현되었기 때문
- 어떻게:
- 원인 확인: `ContentSettingsView`는 로컬 토스트(`viewModel.isShowPopup`)만 표시하고, 캐릭터 리스트 경로는 `AppState` 전역 토스트 상태 타이밍에 의존
- `AppState`에 일회성 전달 상태 추가
- `pendingContentSettingsGuideMessage`
- `setPendingContentSettingsGuideMessage(_:)`
- `consumePendingContentSettingsGuideMessage()`
- `ChatTabView.moveToContentSettingsWithGuideToast()`에서 전역 토스트 토글 대신
- 안내 문구를 pending 상태로 저장
- `.contentViewSettings` 이동만 수행
- `ContentSettingsView.onAppear`에서 pending 문구를 consume하여
- `viewModel.errorMessage` 설정
- `viewModel.isShowPopup = true`로 로컬 토스트 즉시 노출
- analyze-mode 병렬 탐색 결과(`Trace content settings toast suppression`, `Find reliable post-redirect notice patterns`)를 반영해 최소 변경으로 해결
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/App/AppState.swift")`
- `lsp_diagnostics("SodaLive/Sources/Chat/ChatTabView.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsView.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`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경 한계로 모듈/스코프 미해결 오류를 보고했으나 실제 빌드는 통과
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 non-KR + 민감 콘텐츠 OFF → 캐릭터 탭 → 콘텐츠 설정 진입 직후 안내 토스트 노출 확인 필요)

View File

@@ -0,0 +1,64 @@
# 20260327 캐릭터 상세 진입 인증 국가 분기 적용
## 개요
- 캐릭터(또는 크리에이터) 터치로 상세 페이지로 이동할 때 수행되는 인증 체크를 접속국가 기준으로 분기한다.
- 한국(`KR`) 사용자는 기존 본인인증 체크를 유지하고, 비한국 사용자는 콘텐츠 보기 설정 경로를 안내하는 기존 정책과 동일하게 맞춘다.
## 요구사항 요약
- 대상: 캐릭터 상세 진입 탭 핸들러의 인증 가드 로직
- 변경 사항:
- `KR`(정규화 기준, 빈값 포함) 사용자: 기존 본인인증 체크 유지
- `non-KR` 사용자: 인증 대신 콘텐츠 보기 설정 유도 정책 적용
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 캐릭터 상세 진입 인증 가드에서 국가코드 정규화(`trim + uppercased`)가 적용된다.
- [x] AC2: 한국 사용자는 기존 본인인증 체크 흐름이 유지된다.
- [x] AC3: 비한국 사용자는 콘텐츠 보기 설정 유도 분기로 동작한다.
- [x] AC4: 기존 네비게이션/팝업 흐름과 충돌 없이 동작한다.
- [x] AC5: 빌드/진단/테스트 시도 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] 캐릭터 상세 진입 인증 체크 위치 식별
- [x] 기존 국가 분기 정책 스니펫 확인 및 재사용 지점 선정
- [x] 탭 핸들러 분기 로직 변경
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 기록 문서화
## 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 캐릭터 상세 진입 인증 국가 분기 적용 작업 계획 문서 작성
- 왜: 구현 전 변경 범위와 완료 기준을 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항/완료기준/검증계획을 체크리스트로 구성
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 캐릭터 상세 진입 인증 체크를 국가코드 기준(KR/non-KR)으로 분기 적용
- 왜: 기존 “모든 국가 auth 필수” 로직을 정책 변경사항(한국은 본인인증, 비한국은 콘텐츠 보기 설정 유도)에 맞추기 위함
- 어떻게:
- `ChatTabView.handleCharacterSelection(_:)``HomeTabView.handleCharacterSelection(_:)``countryCode` 정규화(`trim + uppercased`) 추가
- `isKoreanCountry`일 때만 기존 `auth == false` 본인인증 팝업(`isShowAuthConfirmView`) 흐름 유지
- `!isKoreanCountry && !UserDefaults.isAdultContentVisible()` 조건에서
- `AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide`
- `AppState.shared.isShowErrorPopup = true`
- `AppState.shared.setAppStep(step: .contentViewSettings)`
로 유도하고 상세 진입 차단
- 실행 명령/도구:
- 탐색: `task(subagent_type="explore", description="Find character detail auth gate")`, `task(subagent_type="explore", description="Find country branch conventions")`
- 진단: `lsp_diagnostics(ChatTabView.swift)`, `lsp_diagnostics(HomeTabView.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`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고하지만 실제 xcodebuild는 통과하여 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경에서 UI 탭/팝업 플로우 실행이 불가하여 미실행(실기기/시뮬레이터에서 KR/non-KR 분기 확인 필요)

View File

@@ -0,0 +1,72 @@
# 20260327 콘텐츠 설정 PATCH 변경 필드 옵셔널 전송
## 개요
- `userApi.updateContentPreference` 요청 시 `contentType`, `isAdultContentVisible`를 항상 같이 보내지 않고, 실제 변경된 필드만 PATCH payload에 포함되도록 수정한다.
- 요청 모델을 optional 파라미터로 변경하고, 기존 UI/동기화 동작은 유지한다.
## 요구사항 요약
- 대상 API: `PATCH /member/content-preference`
- 변경 사항:
- 요청 DTO의 `contentType`, `isAdultContentVisible`를 optional로 전환
- 토글 변경 시에는 `isAdultContentVisible`만 전송
- 콘텐츠 타입 변경 시에는 `contentType`만 전송
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `UpdateContentPreferenceRequest`가 optional 필드를 사용한다.
- [x] AC2: 토글 변경 요청 payload에 `isAdultContentVisible`만 포함된다.
- [x] AC3: 콘텐츠 타입 변경 요청 payload에 `contentType`만 포함된다.
- [x] AC4: 기존 debounce/로딩/에러 처리 흐름이 유지된다.
- [x] AC5: 빌드/진단 검증 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] `UpdateContentPreferenceRequest` optional 필드 전환
- [x] `ContentSettingsViewModel` 요청 생성 로직을 변경 필드 기반으로 분기
- [x] `UserApi`/`UserRepository` 호출부 영향 점검
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단:
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift")`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 콘텐츠 설정 PATCH 변경 필드 옵셔널 전송 작업 계획 문서 작성
- 왜: 구현 범위와 검증 절차를 선행 고정하여 요청사항을 정확히 반영하기 위함
- 어떻게: 기존 docs 포맷 기준으로 완료 기준/체크리스트/검증 계획 수립
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: `updateContentPreference`를 변경 필드만 전송하도록 optional request + diff 기반 전송 로직 적용
- 왜: PATCH 호출 시 `contentType`/`isAdultContentVisible`를 항상 함께 보내지 않고 실제 변경 필드만 서버에 전달하기 위함
- 어떻게:
- `UpdateContentPreferenceRequest``Bool?`/`ContentType?`로 변경하고 `isEmpty` 계산 프로퍼티 추가
- `ContentSettingsViewModel``lastSyncedState`를 추가해 이전 동기화 상태 대비 변경 필드를 계산
- `makeUpdateContentPreferenceRequest(from:to:)`에서 변경된 값만 request에 채우고, 빈 요청은 API 호출 생략
- 서버 성공 응답 시 `applyServerState`에서 `lastSyncedState`를 갱신해 후속 diff 기준 일관성 유지
- search-mode 준수를 위해 explore 에이전트 2개 병렬 실행으로 호출 흐름/optional 패턴 교차 확인
- 실행 명령/도구:
- Background agents:
- `task(subagent_type="explore", description="Trace content-preference flow")`
- `task(subagent_type="explore", description="Find optional PATCH patterns")`
- 코드/진단:
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsViewModel.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`
- 결과:
- `SodaLive`/`SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 모듈 해석 한계로 다수 에러를 반환했으나, 실제 xcodebuild 통과로 컴파일 정상 확인
- 코드상으로 토글 변경 시 `isAdultContentVisible`만, 타입 변경 시 `contentType`만 request에 포함되도록 반영 완료