Files
sodalive-ios/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift
T

593 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) {
if viewModel.isSearchMode || !viewModel.items.isEmpty {
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)
.appFont(.body2)
.foregroundColor(.white)
Text("\(viewModel.items.count)")
.appFont(.body2)
.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(.body1)
.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)
}
}