fix(creator): 본인 채널 DM 확인을 추가한다

This commit is contained in:
Yu Sung
2026-07-12 18:48:27 +09:00
parent caa74c607d
commit 4f271a77ee
18 changed files with 63 additions and 12 deletions

View File

@@ -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
@@ -192,6 +193,16 @@ class AppState: ObservableObject {
return message
}
func setPendingMainChatFilter(_ filter: MainChatFilter) {
pendingMainChatFilter = filter
}
func consumePendingMainChatFilter() -> MainChatFilter? {
let filter = pendingMainChatFilter
pendingMainChatFilter = nil
return filter
}
// ( -> ) UI
func softRestart() {
isRestartApp = true

View File

@@ -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: "ライブを作成") }

View File

@@ -123,7 +123,8 @@ struct CreatorChannelAudioTabView: View {
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

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

View File

@@ -10,7 +10,7 @@ struct CreatorChannelCommunityEmptyView: View {
.lineSpacing(SodaSpacing.s4)
}
.frame(maxWidth: .infinity)
.padding(.top, 316)
.padding(.top, 20)
.padding(.bottom, 120)
}
}

View File

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

View File

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

View File

@@ -36,7 +36,8 @@ struct CreatorChannelDonationEmptyView: View {
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

@@ -36,6 +36,7 @@ struct CreatorChannelFanTalkEmptyView: View {
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

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

View File

@@ -105,7 +105,8 @@ struct CreatorChannelLiveTabView: View {
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

@@ -94,7 +94,8 @@ struct CreatorChannelSeriesTabView: View {
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
.padding(.top, 20)
.padding(.bottom, 48)
}
}

View File

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

View File

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

View File

@@ -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`에 포함하지 않는다.
- 검증 기준:

View File

@@ -74,7 +74,7 @@ data class CreatorChannelAudioThemeResponse(
- Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다.
### 5.2 Theme filter
- 서버 응답 `themes` 앞에 클라이언트 전용 전체 항목을 추가한다.
- 서버 응답 `themes` 조회가 완료된 뒤 맨 앞에 클라이언트 전용 전체 항목을 추가한다.
- 서버 응답 `themeId == nil`이면 전체가 선택된 상태여야 한다.
- 테마 선택 시 첫 페이지부터 다시 조회한다.
- 전체 선택 시 `themeId`를 보내지 않는다.

View File

@@ -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 채팅방 진입은 유지했다.

View File

@@ -31,6 +31,7 @@
- UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다.
- 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다.
- 단, 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고 메인 대화 탭의 DM 필터로 이동한다.
- `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다.
---