diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index b4bdb755..4f126e82 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -64,6 +64,7 @@ class AppState: ObservableObject { @Published var eventPopup: EventItem? = nil @Published var purchasedContentId = 0 @Published var purchasedContentOrderType = OrderType.KEEP + @Published private(set) var pendingMainChatFilter: MainChatFilter? = nil @Published var isRestartApp = false @Published var startTab: HomeViewModel.CurrentTab = .home @@ -191,6 +192,16 @@ class AppState: ObservableObject { pendingContentSettingsGuideMessage = nil return message } + + func setPendingMainChatFilter(_ filter: MainChatFilter) { + pendingMainChatFilter = filter + } + + func consumePendingMainChatFilter() -> MainChatFilter? { + let filter = pendingMainChatFilter + pendingMainChatFilter = nil + return filter + } // 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침 func softRestart() { diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index d72277df..eeb41f37 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3435,6 +3435,7 @@ If you block this user, the following features will be restricted. static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") } static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") } static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") } + static var checkDm: String { pick(ko: "DM 확인하기", en: "Check DM", ja: "DMを確認") } static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") } static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") } static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") } diff --git a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift index ee3d0cfc..0b90a241 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift @@ -123,7 +123,8 @@ struct CreatorChannelAudioTabView: View { .foregroundColor(Color.gray500) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - .padding(.vertical, 48) + .padding(.top, 20) + .padding(.bottom, 48) } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift index 86ba396a..a4eaa92b 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift @@ -26,9 +26,11 @@ final class CreatorChannelAudioViewModel: ObservableObject { @Published var isShowPopup = false var displayThemes: [CreatorChannelAudioThemeItem] { - let themes = response?.themes.map { + guard let response else { return [] } + + let themes = response.themes.map { CreatorChannelAudioThemeItem(themeId: $0.themeId, themeName: $0.themeName) - } ?? [] + } return [CreatorChannelAudioThemeItem.all] + themes } diff --git a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift index 359d23d1..425645ce 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift @@ -10,7 +10,7 @@ struct CreatorChannelCommunityEmptyView: View { .lineSpacing(SodaSpacing.s4) } .frame(maxWidth: .infinity) - .padding(.top, 316) + .padding(.top, 20) .padding(.bottom, 120) } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift index d75ceb41..926ff5f2 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift @@ -3,6 +3,7 @@ import Kingfisher struct CreatorChannelHeaderSection: View { let creator: CreatorChannelCreatorResponse + let isOwnCreatorChannel: Bool let onTapTalk: () -> Void let onTapDm: () -> Void @@ -54,6 +55,7 @@ struct CreatorChannelHeaderSection: View { CreatorChannelTalkActionSection( isAiChatAvailable: creator.isAiChatAvailable, isDmAvailable: creator.isDmAvailable, + isOwnCreatorChannel: isOwnCreatorChannel, onTapTalk: onTapTalk, onTapDm: onTapDm ) diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index d06f4bd7..af55720d 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -236,6 +236,7 @@ struct CreatorChannelView: View { if let creator = viewModel.response?.creator { CreatorChannelHeaderSection( creator: creator, + isOwnCreatorChannel: isOwnCreatorChannel, onTapTalk: handleTalkTap, onTapDm: handleDmTap ) @@ -557,6 +558,12 @@ struct CreatorChannelView: View { return } + if isOwnCreatorChannel { + AppState.shared.setPendingMainChatFilter(.dm) + AppState.shared.setAppStep(step: .main) + return + } + AppState.shared.setAppStep(step: .userCreatorChatCreator(creatorId: creatorId)) } diff --git a/SodaLive/Sources/V2/CreatorChannel/Donation/Components/CreatorChannelDonationEmptyView.swift b/SodaLive/Sources/V2/CreatorChannel/Donation/Components/CreatorChannelDonationEmptyView.swift index 013cdfa3..dacb715e 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Donation/Components/CreatorChannelDonationEmptyView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Donation/Components/CreatorChannelDonationEmptyView.swift @@ -36,7 +36,8 @@ struct CreatorChannelDonationEmptyView: View { } } .frame(maxWidth: .infinity) - .padding(.vertical, 48) + .padding(.top, 20) + .padding(.bottom, 48) } } diff --git a/SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkEmptyView.swift b/SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkEmptyView.swift index ee473198..9a274f15 100644 --- a/SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkEmptyView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkEmptyView.swift @@ -36,6 +36,7 @@ struct CreatorChannelFanTalkEmptyView: View { } } .frame(maxWidth: .infinity) - .padding(.vertical, 48) + .padding(.top, 20) + .padding(.bottom, 48) } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift index 4c066ce4..b3079840 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift @@ -3,6 +3,7 @@ import SwiftUI struct CreatorChannelTalkActionSection: View { let isAiChatAvailable: Bool let isDmAvailable: Bool + let isOwnCreatorChannel: Bool let onTapTalk: () -> Void let onTapDm: () -> Void @@ -37,7 +38,7 @@ struct CreatorChannelTalkActionSection: View { .resizable() .frame(width: 20, height: 20) - Text(I18n.CreatorChannelHome.dm) + Text(isOwnCreatorChannel ? I18n.CreatorChannelHome.checkDm : I18n.CreatorChannelHome.dm) .appFont(size: 16, weight: .medium) .foregroundColor(.white) } diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift index bb138c0e..a85ba95c 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift @@ -105,7 +105,8 @@ struct CreatorChannelLiveTabView: View { .foregroundColor(Color.gray500) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - .padding(.vertical, 48) + .padding(.top, 20) + .padding(.bottom, 48) } } diff --git a/SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesTabView.swift index 26a23396..d4f89b06 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesTabView.swift @@ -94,7 +94,8 @@ struct CreatorChannelSeriesTabView: View { .foregroundColor(Color.gray500) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - .padding(.vertical, 48) + .padding(.top, 20) + .padding(.bottom, 48) } } diff --git a/SodaLive/Sources/V2/Main/Chat/MainChatView.swift b/SodaLive/Sources/V2/Main/Chat/MainChatView.swift index 9690b80e..e76bbe2d 100644 --- a/SodaLive/Sources/V2/Main/Chat/MainChatView.swift +++ b/SodaLive/Sources/V2/Main/Chat/MainChatView.swift @@ -5,6 +5,7 @@ struct MainChatView: View { let onTapSearch: () -> Void @StateObject private var viewModel = MainChatViewModel() + @StateObject private var appState = AppState.shared var body: some View { VStack(spacing: 0) { @@ -28,7 +29,14 @@ struct MainChatView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .background(Color.black.ignoresSafeArea()) - .onAppear { viewModel.fetchFirstPageIfNeeded() } + .onAppear { + if applyPendingFilterIfNeeded() == false { + viewModel.fetchFirstPageIfNeeded() + } + } + .valueChanged(value: appState.pendingMainChatFilter) { _ in + _ = applyPendingFilterIfNeeded() + } .sodaToast( isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, @@ -89,6 +97,12 @@ struct MainChatView: View { AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId)) } } + + private func applyPendingFilterIfNeeded() -> Bool { + guard let filter = appState.consumePendingMainChatFilter() else { return false } + viewModel.applyFilter(filter) + return true + } } struct MainChatView_Previews: PreviewProvider { diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index e306c021..97dd1980 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -123,6 +123,7 @@ struct MainView: View { .valueChanged(value: appState.pushMessageId) { handlePushMessageId($0) } .valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) } .valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) } + .valueChanged(value: appState.pendingMainChatFilter) { handlePendingMainChatFilter($0) } .valueChanged(value: appState.isShowPlayer) { isShowPlayer in guard !isShowPlayer, let pendingExternalNavigationAction = pendingExternalNavigationAction else { @@ -408,6 +409,11 @@ struct MainView: View { } } + private func handlePendingMainChatFilter(_ filter: MainChatFilter?) { + guard filter != nil else { return } + viewModel.currentTab = .chat + } + private func handlePushAudioContentId(_ value: Int) { guard value > 0 else { return } let contentId = value diff --git a/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md b/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md index 9fc503d4..77d50b55 100644 --- a/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md +++ b/docs/20260704_크리에이터_채널_오디오_탭/plan-task.md @@ -84,7 +84,7 @@ - 생성: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioViewModel.swift` - 작업 내용: - 상태는 loading, response, filtered `audioContents`, selectedSort, selectedTheme, page, size, hasNext, hasLoaded, error toast를 둔다. - - `displayThemes`에서 전체 항목을 맨 앞에 합성한다. + - `displayThemes`에서 서버 `themes` 조회 완료 후 전체 항목을 맨 앞에 합성한다. - 정렬/테마 변경 시 첫 페이지부터 다시 조회한다. - `duration`이 없는 콘텐츠는 `visibleAudioContents`에 포함하지 않는다. - 검증 기준: diff --git a/docs/20260704_크리에이터_채널_오디오_탭/prd.md b/docs/20260704_크리에이터_채널_오디오_탭/prd.md index 5e074b31..4f0c81df 100644 --- a/docs/20260704_크리에이터_채널_오디오_탭/prd.md +++ b/docs/20260704_크리에이터_채널_오디오_탭/prd.md @@ -74,7 +74,7 @@ data class CreatorChannelAudioThemeResponse( - Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다. ### 5.2 Theme filter -- 서버 응답 `themes` 앞에 클라이언트 전용 전체 항목을 추가한다. +- 서버 응답 `themes` 조회가 완료된 뒤 맨 앞에 클라이언트 전용 전체 항목을 추가한다. - 서버 응답 `themeId == nil`이면 전체가 선택된 상태여야 한다. - 테마 선택 시 첫 페이지부터 다시 조회한다. - 전체 선택 시 `themeId`를 보내지 않는다. diff --git a/docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md b/docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md index b51b502a..abeb0d43 100644 --- a/docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md +++ b/docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md @@ -467,3 +467,4 @@ - 2026-07-12 코드 리뷰 결과 반영: 백그라운드 상태와 `openRoom` 성공 여부를 별도 gate로 관리해 지연된 REST 응답과 예약된 재연결이 백그라운드에서 socket을 다시 열지 않게 했다. 새로 관측된 내 서버 텍스트 메시지는 로컬 pending/failed 메시지와 1:1로 재동기화하고, WebSocket의 public 명령과 delegate/receive/send callback에서 발생하는 mutable state 접근을 main thread로 직렬화했다. DM 채팅과 관계없는 기존 `print(error)` 삭제 2건은 원복했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveCodeReviewDerivedData CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다. - 2026-07-12 최신 메시지 REST 재동기화 보완: `JOINED` 후 `syncLatestMessages` 완료까지 Loading을 유지하고, 요청 실패·API 실패 응답·디코딩 오류를 `applyRestFailure` 토스트 경로로 전달하도록 수정했다. `git diff --check HEAD`가 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. - 2026-07-12 추가 코드 리뷰 결과 반영: `JOIN_ROOM` 전송 오류를 즉시 JOIN 실패 event로 전달하고, 15초 동안 `JOINED`가 없으면 현재 socket을 닫은 뒤 기존 최대 3회 재시도 정책을 적용하도록 보완했다. ACK 유실 재동기화 시 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과했고, `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewCurrent CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 종료 코드 0으로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다. +- 2026-07-12 크리에이터 채널 후속 UX 수정: 크리에이터 채널 각 탭 empty state 문구의 상단 여백을 `20`으로 조정해 중앙 배치가 아닌 상단 배치로 변경했다. 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고, tap 시 `AppState.pendingMainChatFilter(.dm)`를 통해 메인 대화 탭의 DM 필터로 이동하도록 반영했다. 타인 채널의 `DM 보내기` 및 creatorId 기반 DM 채팅방 진입은 유지했다. diff --git a/docs/20260711_유저_크리에이터_1대1_채팅/prd.md b/docs/20260711_유저_크리에이터_1대1_채팅/prd.md index 38042c86..22e25660 100644 --- a/docs/20260711_유저_크리에이터_1대1_채팅/prd.md +++ b/docs/20260711_유저_크리에이터_1대1_채팅/prd.md @@ -31,6 +31,7 @@ - UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다. - 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다. - 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다. +- 단, 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고 메인 대화 탭의 DM 필터로 이동한다. - `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다. ---