589 lines
21 KiB
Swift
589 lines
21 KiB
Swift
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<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)
|
|
}
|
|
}
|