feat(chat): 크리에이터 DM 시작을 추가한다

This commit is contained in:
Yu Sung
2026-09-14 22:03:46 +09:00
parent aea4e5558c
commit e486642a77
9 changed files with 505 additions and 2 deletions
@@ -124,7 +124,7 @@ struct MainChatView: View {
private func applyPendingFilterIfNeeded() -> Bool {
guard let filter = appState.consumePendingMainChatFilter() else { return false }
viewModel.applyFilter(filter)
viewModel.fetchFirstPage(filter: filter)
return true
}
}
@@ -1,7 +1,24 @@
import Foundation
struct UserCreatorCreateRoomRequest: Encodable {
let creatorId: Int
let recipientId: Int?
let creatorId: Int?
enum CodingKeys: String, CodingKey {
case recipientId
case creatorId
}
init(recipientId: Int? = nil, creatorId: Int? = nil) {
self.recipientId = recipientId
self.creatorId = creatorId
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(recipientId, forKey: .recipientId)
try container.encodeIfPresent(creatorId, forKey: .creatorId)
}
}
struct UserCreatorCreateRoomResponse: Decodable {
@@ -10,6 +10,10 @@ final class UserCreatorChatRepository {
return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(creatorId: creatorId)))
}
func createRoom(recipientId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(recipientId: recipientId)))
}
func openRoom(roomId: Int, limit: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.openRoom(roomId: roomId, limit: limit))
}
@@ -1,5 +1,6 @@
import SwiftUI
import UIKit
import Combine
struct UserCreatorChatRoomView: View {
@StateObject private var viewModel = UserCreatorChatRoomViewModel()
@@ -200,3 +201,388 @@ struct UserCreatorChatRoomView_Previews: PreviewProvider {
UserCreatorChatRoomView(roomId: 1)
}
}
struct UserCreatorChatRecipientItem: Identifiable, Equatable {
let id: Int
let nickname: String
let profileImageUrl: String
init(follower item: GetFollowerListResponseItem) {
self.id = item.userId
self.nickname = item.nickname
self.profileImageUrl = item.profileImage
}
init(search item: GetRoomDetailUser) {
self.id = item.id
self.nickname = item.nickname
self.profileImageUrl = item.profileImageUrl
}
}
final class UserCreatorChatRecipientSearchViewModel: ObservableObject {
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var searchText = ""
@Published private(set) var items = [UserCreatorChatRecipientItem]()
@Published private(set) var isSearchMode = false
@Published private(set) var isCreatingRoom = false
private let followRepository = ExplorerRepository()
private let userRepository = UserRepository()
private let chatRepository = UserCreatorChatRepository()
private var subscription = Set<AnyCancellable>()
private var searchSubscription: AnyCancellable?
private var followingItems = [UserCreatorChatRecipientItem]()
private var page = 1
private var isLast = false
private let pageSize = 10
private var isFetchingFollowing = false
private var isSearching = false
private var didCompleteSearch = false
private var activeSearchQuery: String?
var shouldShowEmptySearchResult: Bool {
isSearchMode && didCompleteSearch && !isSearching && items.isEmpty
}
init() {
$searchText
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.removeDuplicates()
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] query in
self?.handleSearchQuery(query)
}
.store(in: &subscription)
}
func fetchFirstPageIfNeeded() {
guard followingItems.isEmpty else { return }
fetchFollowing()
}
func fetchNextPageIfNeeded(currentItem item: UserCreatorChatRecipientItem) {
guard !isSearchMode, item.id == followingItems.last?.id else { return }
fetchFollowing()
}
func createRoom(recipientId: Int) {
guard recipientId > 0, !isCreatingRoom else { return }
isCreatingRoom = true
updateLoading()
chatRepository.createRoom(recipientId: recipientId)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyCreateRoomFailure(message: nil)
}
} receiveValue: { [weak self] response in
guard let self else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorCreateRoomResponse>.self, from: response.data)
if let data = decoded.data, decoded.success, data.roomId > 0 {
self.isCreatingRoom = false
self.updateLoading()
AppState.shared.requestMainChatRefresh(filter: .dm)
AppState.shared.replaceCurrentAppStep(step: .userCreatorChatRoom(roomId: data.roomId))
} else {
self.applyCreateRoomFailure(message: decoded.message)
}
} catch {
ERROR_LOG(error.localizedDescription)
self.applyCreateRoomFailure(message: nil)
}
}
.store(in: &subscription)
}
private func handleSearchQuery(_ query: String) {
guard query.count >= 2 else {
searchSubscription?.cancel()
activeSearchQuery = nil
isSearching = false
didCompleteSearch = false
isSearchMode = false
items = followingItems
updateLoading()
return
}
search(query: query)
}
private func fetchFollowing() {
guard !isLast, !isFetchingFollowing else { return }
isFetchingFollowing = true
updateLoading()
followRepository.getFollowerList(userId: UserDefaults.int(forKey: .userId), page: page, size: pageSize)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.isFetchingFollowing = false
self.applyFailure(message: nil)
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isFetchingFollowing = false
self.updateLoading()
do {
let decoded = try JSONDecoder().decode(ApiResponse<GetFollowerListResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
let mappedItems = data.items.map(UserCreatorChatRecipientItem.init(follower:))
if self.page == 1 {
self.followingItems.removeAll()
}
self.followingItems.append(contentsOf: mappedItems)
self.items = self.isSearchMode ? self.items : self.followingItems
if data.items.isEmpty {
self.isLast = true
} else {
self.page += 1
}
} else {
self.applyFailure(message: decoded.message)
}
} catch {
ERROR_LOG(error.localizedDescription)
self.applyFailure(message: nil)
}
}
.store(in: &subscription)
}
private func search(query: String) {
isSearchMode = true
isSearching = true
didCompleteSearch = false
items.removeAll()
updateLoading()
searchSubscription?.cancel()
activeSearchQuery = query
searchSubscription = userRepository.searchUser(nickname: query)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
guard self.activeSearchQuery == query else { return }
self.isSearching = false
self.applyFailure(message: nil)
}
} receiveValue: { [weak self] response in
guard let self else { return }
guard self.activeSearchQuery == query else { return }
self.isSearching = false
self.updateLoading()
do {
let decoded = try JSONDecoder().decode(ApiResponse<[GetRoomDetailUser]>.self, from: response.data)
if let data = decoded.data, decoded.success {
self.items = data.map(UserCreatorChatRecipientItem.init(search:))
self.didCompleteSearch = true
} else {
self.applyFailure(message: decoded.message)
}
} catch {
ERROR_LOG(error.localizedDescription)
self.applyFailure(message: nil)
}
}
}
private func applyFailure(message: String?) {
updateLoading()
errorMessage = message ?? I18n.Common.commonError
isShowPopup = true
}
private func applyCreateRoomFailure(message: String?) {
isCreatingRoom = false
applyFailure(message: message)
}
private func updateLoading() {
isLoading = isFetchingFollowing || isSearching || isCreatingRoom
}
}
struct UserCreatorChatRecipientSearchView: View {
@StateObject private var viewModel = UserCreatorChatRecipientSearchViewModel()
@State private var selectedRecipient: UserCreatorChatRecipientItem?
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
ZStack {
VStack(spacing: 0) {
searchBar
.padding(.top, SodaSpacing.s8)
listContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded { hideKeyboard() })
}
.background(Color.black.ignoresSafeArea())
.onAppear { viewModel.fetchFirstPageIfNeeded() }
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
autohideIn: 2
)
if let selectedRecipient {
confirmationDialog(for: selectedRecipient)
}
}
}
}
private var searchBar: some View {
HStack(spacing: SodaSpacing.s8) {
Image("ic_back")
.resizable()
.frame(width: 24, height: 24)
.onTapGesture { AppState.shared.back() }
HStack(spacing: SodaSpacing.s8) {
Image(systemName: "magnifyingglass")
.foregroundColor(Color.gray500)
TextField(I18n.UserCreatorChatRecipientSearch.searchPlaceholder, text: $viewModel.searchText)
.appFont(size: 14, weight: .regular)
.foregroundColor(.white)
.accentColor(Color.soda400)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
}
.padding(.horizontal, SodaSpacing.s12)
.frame(height: 42)
.background(Color.gray900)
.cornerRadius(21)
}
.padding(.horizontal, SodaSpacing.s14)
.frame(width: screenSize().width, height: 54)
}
@ViewBuilder
private var listContent: some View {
if viewModel.shouldShowEmptySearchResult {
VStack(alignment: .leading, spacing: 0) {
sectionHeader
emptySearchContent
}
.padding(.top, SodaSpacing.s8)
} else {
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 0) {
sectionHeader
ForEach(viewModel.items) { item in
UserCreatorChatRecipientRow(item: item) {
hideKeyboard()
selectedRecipient = item
}
.onAppear {
viewModel.fetchNextPageIfNeeded(currentItem: item)
}
}
}
.padding(.top, SodaSpacing.s8)
}
}
}
@ViewBuilder
private var sectionHeader: some View {
if viewModel.isSearchMode {
HStack(spacing: SodaSpacing.s4) {
Text(I18n.UserCreatorChatRecipientSearch.searchResultLabel)
.foregroundColor(.white)
Text("\(viewModel.items.count)")
.foregroundColor(Color.gray500)
}
.appFont(size: 14, weight: .bold)
.padding(.horizontal, SodaSpacing.s14)
.frame(maxWidth: .infinity, minHeight: 40, alignment: .leading)
} else {
Text(I18n.UserCreatorChatRecipientSearch.sectionTitle)
.appFont(size: 14, weight: .bold)
.foregroundColor(.white)
.padding(.horizontal, SodaSpacing.s14)
.frame(height: 40, alignment: .leading)
}
}
private var emptySearchContent: some View {
Text(I18n.UserCreatorChatRecipientSearch.emptyMessage)
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func confirmationDialog(for item: UserCreatorChatRecipientItem) -> some View {
SodaV2ActionModal(
title: I18n.UserCreatorChatRecipientSearch.dialogTitle,
message: I18n.UserCreatorChatRecipientSearch.dialogMessage(item.nickname),
button1: SodaV2ActionModalButton(
label: I18n.UserCreatorChatRecipientSearch.sendButton,
action: {
selectedRecipient = nil
viewModel.createRoom(recipientId: item.id)
}
),
button2: SodaV2ActionModalButton(
label: I18n.Common.cancel,
action: { selectedRecipient = nil }
),
onDimmedTap: { selectedRecipient = nil }
)
}
}
private struct UserCreatorChatRecipientRow: View {
let item: UserCreatorChatRecipientItem
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: SodaSpacing.s12) {
DownsampledKFImage(
url: URL(string: item.profileImageUrl),
size: CGSize(width: 42, height: 42)
)
.clipShape(Circle())
Text(item.nickname)
.appFont(size: 14, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.padding(.horizontal, SodaSpacing.s14)
.frame(height: 66)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
+32
View File
@@ -124,6 +124,26 @@ struct MainView: View {
)
}
if shouldShowChatRecipientSearchButton {
VStack {
Spacer()
HStack {
Spacer()
CreatorChannelFloatingIconButton(
imageName: "ic_plus_no_bg",
backgroundColor: Color.soda400,
accessibilityLabel: I18n.UserCreatorChatRecipientSearch.openButtonAccessibilityLabel,
action: showUserCreatorChatRecipientSearch
)
.padding(.trailing, SodaSpacing.s14)
}
}
.ignoresSafeArea(.container, edges: .bottom)
.padding(.bottom, chatRecipientSearchButtonBottomPadding)
}
if isShowPlayer {
ContentPlayerView(isShowing: $isShowPlayer, playlist: [])
}
@@ -161,6 +181,10 @@ struct MainView: View {
UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue && viewModel.currentTab == .home
}
private var shouldShowChatRecipientSearchButton: Bool {
UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue && viewModel.currentTab == .chat
}
private var creatorActionMenuBottomPadding: CGFloat {
var bottomPadding = mainTabBarHeight + SodaSpacing.s14
if contentPlayerPlayManager.isShowingMiniPlayer {
@@ -172,6 +196,10 @@ struct MainView: View {
return bottomPadding
}
private var chatRecipientSearchButtonBottomPadding: CGFloat {
creatorActionMenuBottomPadding
}
@ViewBuilder
private var contentView: some View {
switch viewModel.currentTab {
@@ -686,6 +714,10 @@ struct MainView: View {
appState.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: handleMainCreateLiveSuccess))
}
private func showUserCreatorChatRecipientSearch() {
appState.setAppStep(step: .userCreatorChatRecipientSearch)
}
private func handleMainCreateLiveSuccess(response: CreateLiveRoomResponse) {
liveViewModel.getLiveMain()
if let _ = response.channelName {