import SwiftUI import UIKit import Combine struct UserCreatorChatRoomView: View { @StateObject private var viewModel = UserCreatorChatRoomViewModel() @State private var messageText = "" private let roomId: Int? private let creatorId: Int? init(roomId: Int) { self.roomId = roomId self.creatorId = nil } init(creatorId: Int) { self.roomId = nil self.creatorId = creatorId } var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { headerView messageListView .frame(maxWidth: .infinity, maxHeight: .infinity) inputBarView } .background(Color.black.ignoresSafeArea()) } .onAppear(perform: enterRoom) .onDisappear { viewModel.leaveAndClose() } .sodaToast( isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2 ) } private var headerView: some View { HStack(spacing: SodaSpacing.s12) { Image("ic_back") .resizable() .frame(width: 24, height: 24) .onTapGesture { AppState.shared.back() } DownsampledKFImage( url: URL(string: viewModel.opponentProfileImageUrl ?? ""), size: CGSize(width: 36, height: 36) ) .clipShape(Circle()) Text(viewModel.opponentNickname) .appFont(size: 14, weight: .bold) .foregroundColor(.white) .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 0) } .padding(.horizontal, SodaSpacing.s16) .padding(.vertical, SodaSpacing.s8) .frame(width: screenSize().width, height: 60) .background(Color.black) } private var messageListView: some View { GeometryReader { geometry in ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: SodaSpacing.s16) { if viewModel.isLoadingNextPage { ProgressView() .progressViewStyle(.circular) .tint(Color.soda400) .padding(.vertical, SodaSpacing.s12) } ForEach(textMessages) { message in UserCreatorChatTextMessageItemView(message: message) .id(message.id) .onAppear { if message.id == textMessages.first?.id { viewModel.loadMore() } } } } .padding(.horizontal, SodaSpacing.s24) .padding(.vertical, SodaSpacing.s12) .background( GeometryReader { contentGeometry in Color.clear.preference( key: UserCreatorChatContentHeightPreferenceKey.self, value: contentGeometry.size.height ) } ) .frame(minHeight: geometry.size.height, alignment: .bottom) } .onPreferenceChange(UserCreatorChatContentHeightPreferenceKey.self) { contentHeight in guard contentHeight <= geometry.size.height else { return } viewModel.loadMore() } .onChange(of: textMessages.last?.id) { _ in scrollToBottom(proxy) } .onChange(of: viewModel.socketState) { state in guard state == .joined else { return } scrollToBottom(proxy) } } } } private var inputBarView: some View { HStack(spacing: SodaSpacing.s8) { ZStack(alignment: .leading) { if messageText.isEmpty { Text(I18n.UserCreatorChat.messagePlaceholder) .appFont(size: 14, weight: .regular) .foregroundColor(Color.gray500) } TextField("", text: $messageText) .appFont(size: 14, weight: .regular) .foregroundColor(.white) .accentColor(Color.soda400) .disabled(isInputEnabled == false) .onSubmit { sendText() } } .padding(.horizontal, SodaSpacing.s16) .padding(.vertical, SodaSpacing.s12) .background(Color.gray900) .cornerRadius(999) Button(action: sendText) { Image("ic_message_send") .resizable() .frame(width: 24, height: 24) .opacity(isSendEnabled ? 1 : 0.4) } .buttonStyle(.plain) .disabled(isSendEnabled == false) } .padding(.horizontal, SodaSpacing.s12) .padding(.vertical, SodaSpacing.s12) .frame(width: screenSize().width) .background(Color.black) } private var textMessages: [UserCreatorChatDisplayMessage] { viewModel.messages.filter { $0.messageType == "TEXT" } } private var isInputEnabled: Bool { viewModel.socketState == .joined } private var isSendEnabled: Bool { isInputEnabled && messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } private func enterRoom() { if let roomId { viewModel.enter(roomId: roomId) } else if let creatorId { viewModel.enter(creatorId: creatorId) } } private func sendText() { let trimmedText = messageText.trimmingCharacters(in: .whitespacesAndNewlines) guard isInputEnabled, trimmedText.isEmpty == false else { return } UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) viewModel.sendText(trimmedText) messageText = "" } private func scrollToBottom(_ proxy: ScrollViewProxy) { guard let lastMessage = textMessages.last else { return } withAnimation(.easeOut(duration: 0.3)) { proxy.scrollTo(lastMessage.id, anchor: .bottom) } } } private struct UserCreatorChatContentHeightPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } struct UserCreatorChatRoomView_Previews: PreviewProvider { static var previews: some View { 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) } }