diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index e7efdea0..84d8d6a8 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -138,6 +138,19 @@ class AppState: ObservableObject { } } } + + func replaceCurrentAppStep(step: AppStep) { + DispatchQueue.main.async { + if let route = self.navigationPath.popLast() { + self.routeStepMap.removeValue(forKey: route) + } + + let route = AppRoute() + self.routeStepMap[route] = step + self.navigationPath.append(route) + self.appStep = step + } + } func back() { DispatchQueue.main.async { @@ -244,6 +257,10 @@ class AppState: ObservableObject { pendingMainChatFilter = filter } + func requestMainChatRefresh(filter: MainChatFilter) { + pendingMainChatFilter = filter + } + func consumePendingMainChatFilter() -> MainChatFilter? { let filter = pendingMainChatFilter pendingMainChatFilter = nil diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 37e01573..b1afa2df 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -185,6 +185,8 @@ enum AppStep { case userCreatorChatCreator(creatorId: Int) + case userCreatorChatRecipientSearch + case newCharacterAll case originalWorkDetail(originalId: Int) diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 587287f8..6134ade6 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -354,6 +354,9 @@ struct AppStepLayerView: View { case .userCreatorChatCreator(let creatorId): UserCreatorChatRoomView(creatorId: creatorId) + case .userCreatorChatRecipientSearch: + UserCreatorChatRecipientSearchView() + case .newCharacterAll: NewCharacterListView() diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index de2f6593..836a6aa2 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -33,6 +33,48 @@ enum I18n { } } + enum UserCreatorChatRecipientSearch { + static var searchPlaceholder: String { + pick(ko: "팬 이름을 입력하세요", en: "Enter a fan name", ja: "ファンの名前を入力してください") + } + + static var sectionTitle: String { + pick(ko: "팔로워", en: "Followers", ja: "フォロワー") + } + + static var searchResultLabel: String { + pick(ko: "검색결과", en: "Search results", ja: "検索結果") + } + + static var emptyMessage: String { + pick( + ko: "사용자가 없어요.\n다른 이름으로 다시 검색해 주세요.", + en: "No users found.\nTry searching with another name.", + ja: "ユーザーがいません。\n別の名前で検索してください。" + ) + } + + static var dialogTitle: String { + pick(ko: "메시지 보내기", en: "Send message", ja: "メッセージを送る") + } + + static func dialogMessage(_ nickname: String) -> String { + pick( + ko: "\(nickname)에게 메시지를 보낼까요?", + en: "Send a message to \(nickname)?", + ja: "\(nickname)にメッセージを送りますか?" + ) + } + + static var sendButton: String { + pick(ko: "보내기", en: "Send", ja: "送信") + } + + static var openButtonAccessibilityLabel: String { + pick(ko: "메시지 보낼 사용자 선택", en: "Select user to message", ja: "メッセージを送るユーザーを選択") + } + } + // 채팅방(캐릭터 톡) 관련 문자열 enum ChatRoom { // 잠금된 메시지 다이얼로그 diff --git a/SodaLive/Sources/V2/Main/Chat/MainChatView.swift b/SodaLive/Sources/V2/Main/Chat/MainChatView.swift index 4c435128..270cf192 100644 --- a/SodaLive/Sources/V2/Main/Chat/MainChatView.swift +++ b/SodaLive/Sources/V2/Main/Chat/MainChatView.swift @@ -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 } } diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift index deca85fc..186ff6e4 100644 --- a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift @@ -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 { diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift index 0f4af349..5efc1f56 100644 --- a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift @@ -10,6 +10,10 @@ final class UserCreatorChatRepository { return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(creatorId: creatorId))) } + func createRoom(recipientId: Int) -> AnyPublisher { + return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(recipientId: recipientId))) + } + func openRoom(roomId: Int, limit: Int) -> AnyPublisher { return api.requestPublisher(.openRoom(roomId: roomId, limit: limit)) } diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift index 890e4839..1bc49abc 100644 --- a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift @@ -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() + 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.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.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) + } +} diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 321a88da..18883045 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -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 {