1221 lines
44 KiB
Swift
1221 lines
44 KiB
Swift
//
|
||
// ChatsTab.swift
|
||
// VolnahubApp
|
||
//
|
||
// Created by cheykrym on 09/06/2025.
|
||
//
|
||
|
||
import SwiftUI
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
|
||
struct ChatsTab: View {
|
||
@ObservedObject private var loginViewModel: LoginViewModel
|
||
@Binding private var pendingNavigation: IncomingMessageCenter.ChatNavigationTarget?
|
||
@Binding var searchRevealProgress: CGFloat
|
||
@Binding var searchText: String
|
||
private let searchService = SearchService()
|
||
private let chatService = ChatService()
|
||
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
|
||
@StateObject private var viewModel = PrivateChatsViewModel()
|
||
@State private var selectedChatId: String?
|
||
@State private var searchDragStartProgress: CGFloat = 0
|
||
@State private var isSearchGestureActive: Bool = false
|
||
@State private var globalSearchResults: [UserSearchResult] = []
|
||
@State private var isGlobalSearchLoading: Bool = false
|
||
@State private var globalSearchError: String?
|
||
@State private var globalSearchTask: Task<Void, Never>? = nil
|
||
@State private var creatingChatForUserId: UUID?
|
||
@State private var chatCreationError: String?
|
||
@State private var isChatCreationAlertPresented: Bool = false
|
||
@State private var pendingChatItem: PrivateChatListItem?
|
||
@State private var isPendingChatActive: Bool = false
|
||
|
||
private let searchRevealDistance: CGFloat = 90
|
||
|
||
private var currentUserId: String? {
|
||
let userId = loginViewModel.userId
|
||
return userId.isEmpty ? nil : userId
|
||
}
|
||
|
||
init(
|
||
loginViewModel: LoginViewModel,
|
||
pendingNavigation: Binding<IncomingMessageCenter.ChatNavigationTarget?>,
|
||
searchRevealProgress: Binding<CGFloat>,
|
||
searchText: Binding<String>
|
||
) {
|
||
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
|
||
self._pendingNavigation = pendingNavigation
|
||
self._searchRevealProgress = searchRevealProgress
|
||
self._searchText = searchText
|
||
}
|
||
|
||
var body: some View {
|
||
content
|
||
.background(Color(UIColor.systemBackground))
|
||
.onAppear {
|
||
viewModel.loadInitialChats()
|
||
handleSearchQueryChange(searchText)
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
|
||
if loginViewModel.chatLoadingState != .loading {
|
||
loginViewModel.chatLoadingState = .loading
|
||
}
|
||
viewModel.refresh()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in
|
||
if loginViewModel.chatLoadingState != .loading {
|
||
loginViewModel.chatLoadingState = .loading
|
||
}
|
||
viewModel.refresh()
|
||
}
|
||
.onChange(of: searchText) { newValue in
|
||
handleSearchQueryChange(newValue)
|
||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !trimmed.isEmpty && searchRevealProgress < 1 {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||
searchRevealProgress = 1
|
||
}
|
||
}
|
||
}
|
||
.alert(
|
||
NSLocalizedString("Не удалось открыть чат", comment: "Chat creation error title"),
|
||
isPresented: Binding(
|
||
get: { isChatCreationAlertPresented && chatCreationError != nil },
|
||
set: { newValue in
|
||
isChatCreationAlertPresented = newValue
|
||
if !newValue {
|
||
chatCreationError = nil
|
||
}
|
||
}
|
||
),
|
||
presenting: chatCreationError
|
||
) { _ in
|
||
Button(NSLocalizedString("Понятно", comment: "Chat creation error acknowledgment"), role: .cancel) { }
|
||
} message: { error in
|
||
Text(error)
|
||
}
|
||
.onDisappear {
|
||
globalSearchTask?.cancel()
|
||
globalSearchTask = nil
|
||
}
|
||
.onChange(of: pendingNavigation?.id) { _ in
|
||
guard let target = pendingNavigation else { return }
|
||
handleNavigationTarget(target.chat)
|
||
DispatchQueue.main.async {
|
||
pendingNavigation = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
|
||
loadingState
|
||
} else {
|
||
chatList
|
||
}
|
||
}
|
||
|
||
private var chatList: some View {
|
||
ZStack {
|
||
List {
|
||
// VStack(spacing: 0) {
|
||
// searchBar
|
||
// .padding(.horizontal, 16)
|
||
// .padding(.vertical, 8)
|
||
// }
|
||
// .background(Color(UIColor.systemBackground))
|
||
|
||
if let message = viewModel.errorMessage {
|
||
Section {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundColor(.orange)
|
||
Text(message)
|
||
.font(.subheadline)
|
||
.foregroundColor(.orange)
|
||
Spacer(minLength: 0)
|
||
Button(action: triggerChatsReload) {
|
||
Text(NSLocalizedString("Обновить", comment: ""))
|
||
.font(.subheadline)
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||
}
|
||
|
||
if isSearching {
|
||
Section(header: localSearchHeader) {
|
||
if localSearchResults.isEmpty {
|
||
emptySearchResultView
|
||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||
.listRowSeparator(.hidden)
|
||
} else {
|
||
ForEach(localSearchResults) { chat in
|
||
chatRowItem(for: chat)
|
||
}
|
||
}
|
||
}
|
||
|
||
Section(header: globalSearchHeader) {
|
||
globalSearchContent
|
||
}
|
||
} else {
|
||
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||
// errorState(message: message)
|
||
// } else
|
||
if viewModel.chats.isEmpty {
|
||
emptyState
|
||
} else {
|
||
|
||
ForEach(viewModel.chats) { chat in
|
||
chatRowItem(for: chat)
|
||
}
|
||
|
||
if viewModel.isLoadingMore {
|
||
loadingMoreRow
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.modifier(ScrollDismissesKeyboardModifier())
|
||
.simultaneousGesture(searchBarGesture)
|
||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||
// .safeAreaInset(edge: .top) {
|
||
// VStack(spacing: 0) {
|
||
// searchBar
|
||
// .padding(.horizontal, 16)
|
||
// .padding(.top, 8)
|
||
// .padding(.bottom, 8)
|
||
// Divider()
|
||
// }
|
||
// .background(Color(UIColor.systemBackground))
|
||
// }
|
||
|
||
pendingChatNavigationLink
|
||
}
|
||
}
|
||
|
||
private var pendingChatNavigationLink: some View {
|
||
NavigationLink(
|
||
destination: pendingChatDestination,
|
||
isActive: Binding(
|
||
get: { isPendingChatActive && pendingChatItem != nil },
|
||
set: { newValue in
|
||
if !newValue {
|
||
isPendingChatActive = false
|
||
pendingChatItem = nil
|
||
}
|
||
}
|
||
)
|
||
) {
|
||
EmptyView()
|
||
}
|
||
.hidden()
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var pendingChatDestination: some View {
|
||
if let pendingChatItem {
|
||
PrivateChatView(chat: pendingChatItem, currentUserId: currentUserId)
|
||
} else {
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
private var searchBarGesture: some Gesture {
|
||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
||
.onChanged { value in
|
||
let verticalTranslation = value.translation.height
|
||
let horizontalTranslation = value.translation.width
|
||
|
||
if !isSearchGestureActive {
|
||
guard abs(verticalTranslation) > abs(horizontalTranslation) else { return }
|
||
if searchRevealProgress <= 0.001 && verticalTranslation < 0 { return }
|
||
isSearchGestureActive = true
|
||
searchDragStartProgress = searchRevealProgress
|
||
}
|
||
|
||
guard isSearchGestureActive else { return }
|
||
|
||
let delta = verticalTranslation / searchRevealDistance
|
||
let newProgress = searchDragStartProgress + delta
|
||
if isSearching && newProgress < 1 {
|
||
searchRevealProgress = 1
|
||
return
|
||
}
|
||
searchRevealProgress = max(0, min(1, newProgress))
|
||
}
|
||
.onEnded { _ in
|
||
guard isSearchGestureActive else { return }
|
||
isSearchGestureActive = false
|
||
|
||
if isSearching {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||
searchRevealProgress = 1
|
||
}
|
||
return
|
||
}
|
||
|
||
let target: CGFloat = searchRevealProgress > 0.5 ? 1 : 0
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||
searchRevealProgress = target
|
||
}
|
||
}
|
||
}
|
||
|
||
private var tapToDismissKeyboardGesture: some Gesture {
|
||
TapGesture().onEnded {
|
||
dismissKeyboard()
|
||
}
|
||
}
|
||
|
||
private var isSearching: Bool {
|
||
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
private var messagePreviewLineLimit: Int {
|
||
switch messageLineLimitSetting {
|
||
case ..<1:
|
||
return 1
|
||
case 1...2:
|
||
return messageLineLimitSetting
|
||
default:
|
||
return 2
|
||
}
|
||
}
|
||
|
||
private var filteredChats: [PrivateChatListItem] {
|
||
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmedQuery.isEmpty else { return viewModel.chats }
|
||
let lowercasedQuery = trimmedQuery.lowercased()
|
||
return viewModel.chats.filter { chat in
|
||
let searchableValues = [
|
||
chat.chatData?.customName,
|
||
chat.chatData?.fullName,
|
||
chat.chatData?.login,
|
||
chat.lastMessage?.content
|
||
]
|
||
return searchableValues
|
||
.compactMap { $0?.lowercased() }
|
||
.contains(where: { $0.contains(lowercasedQuery) })
|
||
}
|
||
}
|
||
|
||
private var localSearchResults: [PrivateChatListItem] {
|
||
Array(filteredChats.prefix(5))
|
||
}
|
||
|
||
private var localSearchHeader: some View {
|
||
Text(NSLocalizedString("Локальные чаты", comment: "Local search section"))
|
||
}
|
||
|
||
private var globalSearchHeader: some View {
|
||
Text(NSLocalizedString("Глобальный поиск", comment: "Global search section"))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var globalSearchContent: some View {
|
||
if isGlobalSearchLoading {
|
||
globalSearchLoadingRow
|
||
} else
|
||
// if let error = globalSearchError {
|
||
// globalSearchErrorRow(message: error)
|
||
// } else
|
||
if globalSearchResults.isEmpty {
|
||
globalSearchEmptyRow
|
||
} else {
|
||
ForEach(globalSearchResults) { user in
|
||
globalSearchRow(for: user)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var emptySearchResultView: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "text.magnifyingglass")
|
||
.font(.system(size: 40))
|
||
.foregroundColor(.secondary)
|
||
Text(NSLocalizedString("Ничего не найдено", comment: ""))
|
||
.font(.headline)
|
||
Text(NSLocalizedString("Попробуйте изменить запрос поиска.", comment: ""))
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
|
||
private var loadingState: some View {
|
||
VStack(spacing: 12) {
|
||
ProgressView()
|
||
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private func errorState(message: String) -> some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "exclamationmark.bubble")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.orange)
|
||
Text(message)
|
||
.font(.body)
|
||
.multilineTextAlignment(.center)
|
||
.foregroundColor(.primary)
|
||
Button(action: triggerChatsReload) {
|
||
Text(NSLocalizedString("Повторить", comment: ""))
|
||
.font(.headline)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "bubble.left")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.secondary)
|
||
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
|
||
.font(.body)
|
||
.foregroundColor(.secondary)
|
||
Button(action: triggerChatsReload) {
|
||
Text(NSLocalizedString("Обновить", comment: ""))
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
.padding()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||
.listRowSeparator(.hidden)
|
||
.listRowBackground(Color.clear)
|
||
}
|
||
|
||
private var loadingMoreRow: some View {
|
||
HStack {
|
||
Spacer()
|
||
ProgressView()
|
||
.padding(.vertical, 12)
|
||
Spacer()
|
||
}
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
|
||
private func triggerChatsReload() {
|
||
if loginViewModel.chatLoadingState != .loading {
|
||
loginViewModel.chatLoadingState = .loading
|
||
}
|
||
viewModel.loadInitialChats(force: true)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
|
||
Button {
|
||
selectedChatId = chat.chatId
|
||
} label: {
|
||
ChatRowView(
|
||
chat: chat,
|
||
currentUserId: currentUserId,
|
||
messageLimitLine: messagePreviewLineLimit
|
||
)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.contextMenu {
|
||
Button(action: {}) {
|
||
Label(NSLocalizedString("Закрепить (скоро)", comment: ""), systemImage: "pin")
|
||
}
|
||
|
||
Button(action: {}) {
|
||
Label(NSLocalizedString("Без звука (скоро)", comment: ""), systemImage: "speaker.slash")
|
||
}
|
||
|
||
Button(role: .destructive, action: {}) {
|
||
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
|
||
}
|
||
}
|
||
.background(
|
||
NavigationLink(
|
||
destination: PrivateChatView(chat: chat, currentUserId: currentUserId),
|
||
tag: chat.chatId,
|
||
selection: $selectedChatId
|
||
) {
|
||
EmptyView()
|
||
}
|
||
.hidden()
|
||
)
|
||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||
// .listRowSeparator(.hidden)
|
||
.onAppear {
|
||
guard !isSearching else { return }
|
||
viewModel.loadMoreIfNeeded(currentItem: chat)
|
||
}
|
||
}
|
||
|
||
private var globalSearchLoadingRow: some View {
|
||
HStack {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle())
|
||
Text(NSLocalizedString("Ищем пользователей…", comment: "Global search loading"))
|
||
.font(.footnote)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.vertical, 12)
|
||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
|
||
private func globalSearchErrorRow(message: String) -> some View {
|
||
Text(message)
|
||
.font(.footnote)
|
||
.foregroundColor(.orange)
|
||
.padding(.vertical, 12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
|
||
private var globalSearchEmptyRow: some View {
|
||
Text(NSLocalizedString("Ничего не найдено", comment: "Global search empty state"))
|
||
.font(.footnote)
|
||
.foregroundColor(.secondary)
|
||
.padding(.vertical, 12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
|
||
private func globalSearchRow(for user: UserSearchResult) -> some View {
|
||
Button {
|
||
openPrivateChat(for: user)
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
Circle()
|
||
.fill(user.isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15))
|
||
.frame(width: 44, height: 44)
|
||
.overlay(
|
||
Text(user.avatarInitial)
|
||
.font(.headline)
|
||
.foregroundColor(user.isOfficial ? .white : Color.accentColor)
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 6) {
|
||
Text(user.displayName)
|
||
.fontWeight(user.isOfficial ? .semibold : .regular)
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
|
||
if user.isOfficial {
|
||
Image(systemName: "checkmark.seal.fill")
|
||
.foregroundColor(Color.accentColor)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
|
||
if let secondary = secondaryLine(for: user) {
|
||
Text(secondary)
|
||
.font(.footnote)
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
if creatingChatForUserId == user.userId {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle())
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(creatingChatForUserId != nil)
|
||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||
}
|
||
}
|
||
|
||
private extension ChatsTab {
|
||
func dismissKeyboard() {
|
||
#if canImport(UIKit)
|
||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||
#endif
|
||
}
|
||
|
||
func handleNavigationTarget(_ chatItem: PrivateChatListItem) {
|
||
dismissKeyboard()
|
||
if !searchText.isEmpty {
|
||
searchText = ""
|
||
}
|
||
if searchRevealProgress > 0 {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||
searchRevealProgress = 0
|
||
}
|
||
}
|
||
|
||
let existingChat = viewModel.chats.first(where: { $0.chatId == chatItem.chatId })
|
||
pendingChatItem = existingChat ?? chatItem
|
||
selectedChatId = chatItem.chatId
|
||
isPendingChatActive = true
|
||
|
||
if existingChat == nil {
|
||
if loginViewModel.chatLoadingState != .loading {
|
||
loginViewModel.chatLoadingState = .loading
|
||
}
|
||
viewModel.refresh()
|
||
}
|
||
}
|
||
|
||
func handleSearchQueryChange(_ query: String) {
|
||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
if trimmed.isEmpty {
|
||
resetGlobalSearch()
|
||
return
|
||
}
|
||
|
||
globalSearchTask?.cancel()
|
||
globalSearchTask = nil
|
||
|
||
guard trimmed.count >= 2 else {
|
||
globalSearchResults = []
|
||
globalSearchError = nil
|
||
isGlobalSearchLoading = false
|
||
return
|
||
}
|
||
|
||
isGlobalSearchLoading = true
|
||
globalSearchError = nil
|
||
|
||
globalSearchTask = Task {
|
||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
|
||
do {
|
||
let data = try await searchService.search(query: trimmed)
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
globalSearchResults = data.users
|
||
isGlobalSearchLoading = false
|
||
globalSearchError = nil
|
||
globalSearchTask = nil
|
||
}
|
||
} catch is CancellationError {
|
||
// Ignore cancellation
|
||
} catch {
|
||
guard !Task.isCancelled else { return }
|
||
await MainActor.run {
|
||
globalSearchResults = []
|
||
isGlobalSearchLoading = false
|
||
globalSearchError = friendlyErrorMessage(for: error)
|
||
globalSearchTask = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func resetGlobalSearch() {
|
||
globalSearchTask?.cancel()
|
||
globalSearchTask = nil
|
||
globalSearchResults = []
|
||
globalSearchError = nil
|
||
isGlobalSearchLoading = false
|
||
}
|
||
|
||
func secondaryLine(for user: UserSearchResult) -> String? {
|
||
if let official = user.officialFullName {
|
||
if let custom = user.preferredCustomName, custom != official {
|
||
return "\(user.loginHandle) (\(custom))"
|
||
}
|
||
// if official != user.loginHandle {
|
||
// return user.loginHandle
|
||
// }
|
||
}
|
||
else if let custom = user.preferredCustomName, custom != user.displayName {
|
||
return custom
|
||
}
|
||
|
||
if let profileLogin = user.profile?.login.trimmingCharacters(in: .whitespacesAndNewlines), !profileLogin.isEmpty {
|
||
let handle = "@\(profileLogin)"
|
||
if handle != user.loginHandle {
|
||
return handle
|
||
}
|
||
}
|
||
|
||
if user.displayName != user.loginHandle {
|
||
return user.loginHandle
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func openPrivateChat(for user: UserSearchResult) {
|
||
guard creatingChatForUserId == nil else { return }
|
||
|
||
dismissKeyboard()
|
||
creatingChatForUserId = user.userId
|
||
chatCreationError = nil
|
||
|
||
chatService.createOrFindPrivateChat(targetUserId: user.userId.uuidString) { result in
|
||
creatingChatForUserId = nil
|
||
|
||
switch result {
|
||
case .success(let data):
|
||
let chatItem = PrivateChatListItem(
|
||
chatId: data.chatId,
|
||
chatType: data.chatType,
|
||
chatData: chatProfile(from: user),
|
||
lastMessage: nil,
|
||
createdAt: nil,
|
||
unreadCount: 0
|
||
)
|
||
|
||
pendingChatItem = chatItem
|
||
isPendingChatActive = true
|
||
viewModel.refresh()
|
||
case .failure(let error):
|
||
chatCreationError = friendlyChatCreationMessage(for: error)
|
||
isChatCreationAlertPresented = true
|
||
}
|
||
}
|
||
}
|
||
|
||
func chatProfile(from user: UserSearchResult) -> ChatProfile {
|
||
let profile = user.profile
|
||
let login = user.login ?? profile?.login
|
||
let fullName = profile?.fullName
|
||
let customName = user.preferredCustomName ?? profile?.customName
|
||
|
||
return ChatProfile(
|
||
userId: user.userId.uuidString,
|
||
login: login,
|
||
fullName: fullName,
|
||
customName: customName,
|
||
bio: profile?.bio,
|
||
lastSeen: profile?.lastSeen,
|
||
createdAt: user.createdAt ?? profile?.createdAt,
|
||
isOfficial: user.isOfficial
|
||
)
|
||
}
|
||
|
||
func friendlyChatCreationMessage(for error: Error) -> String {
|
||
if let chatError = error as? ChatServiceError {
|
||
return chatError.errorDescription ?? NSLocalizedString("Не удалось открыть чат.", comment: "Chat creation fallback")
|
||
}
|
||
|
||
if let networkError = error as? NetworkError {
|
||
switch networkError {
|
||
case .unauthorized:
|
||
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Chat creation unauthorized")
|
||
case .invalidURL, .noResponse:
|
||
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Chat creation connection")
|
||
case .network(let underlying):
|
||
return String(format: NSLocalizedString("Ошибка сети: %@", comment: "Chat creation network error"), underlying.localizedDescription)
|
||
case .server(let statusCode, let data):
|
||
if let data {
|
||
let decoder = JSONDecoder()
|
||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||
if let payload = try? decoder.decode(ErrorResponse.self, from: data) {
|
||
if let detail = payload.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty {
|
||
return detail
|
||
}
|
||
if let message = payload.data?.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
|
||
return message
|
||
}
|
||
}
|
||
|
||
if let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
|
||
return raw
|
||
}
|
||
}
|
||
return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: "Chat creation server status"), "\(statusCode)")
|
||
}
|
||
}
|
||
|
||
return NSLocalizedString("Произошла неизвестная ошибка. Попробуйте позже.", comment: "Chat creation unknown error")
|
||
}
|
||
|
||
|
||
func friendlyErrorMessage(for error: Error) -> String {
|
||
if let searchError = error as? SearchServiceError {
|
||
return searchError.errorDescription ?? NSLocalizedString("Не удалось выполнить поиск.", comment: "Search error fallback")
|
||
}
|
||
|
||
if let networkError = error as? NetworkError {
|
||
switch networkError {
|
||
case .unauthorized:
|
||
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Search error unauthorized")
|
||
case .invalidURL, .noResponse:
|
||
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Search error connection")
|
||
case .network(let underlying):
|
||
return underlying.localizedDescription
|
||
case .server(let statusCode, _):
|
||
return String(format: NSLocalizedString("Сервер вернул ошибку (%d).", comment: "Search error server status"), statusCode)
|
||
}
|
||
}
|
||
|
||
if (error as NSError).code == NSURLErrorCancelled { // доп. подстраховка
|
||
return NSLocalizedString("Поиск отменён.", comment: "Search cancelled")
|
||
}
|
||
|
||
return NSLocalizedString("Произошла неизвестная ошибка.", comment: "Search unknown error")
|
||
}
|
||
}
|
||
|
||
private struct ScrollDismissesKeyboardModifier: ViewModifier {
|
||
func body(content: Content) -> some View {
|
||
if #available(iOS 16.0, *) {
|
||
content.scrollDismissesKeyboard(.interactively)
|
||
} else {
|
||
content
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SearchResultPlaceholderView: View {
|
||
let userId: UUID
|
||
|
||
var body: some View {
|
||
VStack(spacing: 16) {
|
||
Text(NSLocalizedString("Профиль в разработке", comment: "Search placeholder title"))
|
||
.font(.headline)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(NSLocalizedString("Companion ID", comment: "Search placeholder companion title"))
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
Text(userId.uuidString)
|
||
.font(.body.monospaced())
|
||
.contextMenu {
|
||
Button(action: {
|
||
#if canImport(UIKit)
|
||
UIPasteboard.general.string = userId.uuidString
|
||
#endif
|
||
}) {
|
||
Label(NSLocalizedString("Скопировать", comment: "Search placeholder copy"), systemImage: "doc.on.doc")
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding()
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(Color(UIColor.secondarySystemBackground))
|
||
)
|
||
|
||
Text(NSLocalizedString("Здесь появится информация о собеседнике и существующих чатах.", comment: "Search placeholder description"))
|
||
.font(.footnote)
|
||
.foregroundColor(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.top, 40)
|
||
.padding(.horizontal, 24)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.background(Color(UIColor.systemBackground))
|
||
}
|
||
}
|
||
|
||
private struct ChatRowView: View {
|
||
let chat: PrivateChatListItem
|
||
let currentUserId: String?
|
||
let messageLimitLine: Int
|
||
private let avatarSize: CGFloat = 52
|
||
|
||
private var title: String {
|
||
switch chat.chatType {
|
||
case .self:
|
||
return NSLocalizedString("Избранные сообщения", comment: "")
|
||
case .privateChat, .unknown:
|
||
if let custom = chat.chatData?.customName, !custom.isEmpty {
|
||
return custom
|
||
}
|
||
if let full = chat.chatData?.fullName, !full.isEmpty {
|
||
return full
|
||
}
|
||
if let login = chat.chatData?.login, !login.isEmpty {
|
||
return "@\(login)"
|
||
}
|
||
return NSLocalizedString("Неизвестный пользователь", comment: "")
|
||
}
|
||
}
|
||
|
||
private var loginDisplay: String? {
|
||
guard let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty else {
|
||
return nil
|
||
}
|
||
return "@\(login)"
|
||
}
|
||
|
||
private var isOfficial: Bool {
|
||
chat.chatData?.isOfficial ?? false
|
||
}
|
||
|
||
private var officialDisplayName: String? {
|
||
guard isOfficial else { return nil }
|
||
|
||
if let customName = chat.chatData?.customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
|
||
return customName
|
||
}
|
||
|
||
if let name = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||
return NSLocalizedString(name, comment: "")
|
||
}
|
||
|
||
return loginDisplay
|
||
}
|
||
|
||
private var isDeletedUser: Bool {
|
||
guard chat.chatType != .self else { return false }
|
||
let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return login?.isEmpty ?? true
|
||
}
|
||
|
||
private var avatarBackgroundColor: Color {
|
||
if isDeletedUser {
|
||
return Color(.systemGray5)
|
||
}
|
||
return isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15)
|
||
}
|
||
|
||
private var avatarTextColor: Color {
|
||
if isDeletedUser {
|
||
return Color.accentColor
|
||
}
|
||
return isOfficial ? Color.white : Color.accentColor
|
||
}
|
||
|
||
private var messagePreview: String {
|
||
guard let message = chat.lastMessage else {
|
||
return NSLocalizedString("Нет сообщений", comment: "")
|
||
}
|
||
|
||
let body = messageBody(for: message)
|
||
guard let prefix = authorPrefix(for: message) else {
|
||
return body
|
||
}
|
||
return body.isEmpty ? prefix : "\(body)"
|
||
// return body.isEmpty ? prefix : "\(prefix): \(body)"
|
||
}
|
||
|
||
private func messageBody(for message: MessageItem) -> String {
|
||
if let content = trimmed(message.content), !content.isEmpty {
|
||
return content
|
||
}
|
||
|
||
if message.mediaLink != nil {
|
||
return NSLocalizedString("Вложение", comment: "")
|
||
}
|
||
|
||
return NSLocalizedString("Сообщение", comment: "")
|
||
}
|
||
|
||
private func authorPrefix(for message: MessageItem) -> String? {
|
||
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
|
||
if isCurrentUser {
|
||
return NSLocalizedString("Вы", comment: "")
|
||
}
|
||
|
||
let profile = message.senderData ?? chat.chatData
|
||
return displayName(for: profile)
|
||
}
|
||
|
||
private func displayName(for profile: ChatProfile?) -> String? {
|
||
guard let profile else { return nil }
|
||
|
||
let login = trimmed(profile.login)
|
||
let customName = trimmed(profile.customName)
|
||
let fullName = trimmed(profile.fullName)
|
||
let isOfficialProfile = profile.isOfficial
|
||
|
||
if let customName {
|
||
return customName
|
||
}
|
||
|
||
if isOfficialProfile {
|
||
if let fullName {
|
||
return fullName
|
||
}
|
||
if let login {
|
||
return "@\(login)"
|
||
}
|
||
}
|
||
|
||
if let login {
|
||
return "@\(login)"
|
||
}
|
||
|
||
return fullName
|
||
}
|
||
|
||
private func trimmed(_ string: String?) -> String? {
|
||
guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else {
|
||
return nil
|
||
}
|
||
return string
|
||
}
|
||
|
||
private var timestamp: String? {
|
||
let date = chat.lastMessage?.createdAt ?? chat.createdAt
|
||
guard let date else { return nil }
|
||
return ChatRowView.formattedTimestamp(for: date)
|
||
}
|
||
|
||
private var initial: String {
|
||
let sourceName: String
|
||
|
||
if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
sourceName = custom
|
||
} else if let displayName = officialDisplayName {
|
||
sourceName = displayName
|
||
} else if let full = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !full.isEmpty {
|
||
sourceName = full
|
||
} else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
||
sourceName = login
|
||
} else {
|
||
sourceName = NSLocalizedString("Неизвестный пользователь", comment: "")
|
||
}
|
||
|
||
if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) {
|
||
return String(character).uppercased()
|
||
}
|
||
|
||
return "?"
|
||
}
|
||
|
||
private var deletedUserSymbolName: String {
|
||
return "person.slash"
|
||
}
|
||
|
||
private var subtitleColor: Color {
|
||
chat.unreadCount > 0 ? .primary : .secondary
|
||
}
|
||
|
||
private var shouldShowReadStatus: Bool {
|
||
guard let message = chat.lastMessage else { return false }
|
||
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
|
||
return isCurrentUser
|
||
}
|
||
|
||
private var readStatusIconName: String {
|
||
guard let message = chat.lastMessage else { return "" }
|
||
return message.isViewed == true ? "checkmark.circle.fill" : "checkmark.circle"
|
||
}
|
||
|
||
private var readStatusColor: Color {
|
||
guard let message = chat.lastMessage else { return .secondary }
|
||
return message.isViewed == true ? Color.accentColor : Color.secondary
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Circle()
|
||
.fill(avatarBackgroundColor)
|
||
.frame(width: avatarSize, height: avatarSize)
|
||
.overlay(
|
||
Group {
|
||
if isDeletedUser {
|
||
Image(systemName: deletedUserSymbolName)
|
||
.symbolRenderingMode(.hierarchical)
|
||
.font(.system(size: avatarSize * 0.45, weight: .semibold))
|
||
.foregroundColor(avatarTextColor)
|
||
} else {
|
||
Text(initial)
|
||
.font(.system(size: avatarSize * 0.5, weight: .semibold))
|
||
.foregroundColor(avatarTextColor)
|
||
}
|
||
}
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if let officialName = officialDisplayName {
|
||
HStack(spacing: 6) {
|
||
if #available(iOS 16.0, *) {
|
||
Text(officialName)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.strikethrough(isDeletedUser, color: Color.secondary)
|
||
} else {
|
||
Text(officialName)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
|
||
Image(systemName: "checkmark.seal.fill")
|
||
.foregroundColor(Color.accentColor)
|
||
.font(.caption)
|
||
}
|
||
|
||
// if let login = loginDisplay {
|
||
// Text(login)
|
||
// .font(.footnote)
|
||
// .foregroundColor(.secondary)
|
||
// .lineLimit(1)
|
||
// .truncationMode(.tail)
|
||
// }
|
||
} else {
|
||
if #available(iOS 16.0, *) {
|
||
Text(title)
|
||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.strikethrough(isDeletedUser, color: Color.secondary)
|
||
} else {
|
||
Text(title)
|
||
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
|
||
Text(messagePreview)
|
||
.font(.subheadline)
|
||
.foregroundColor(subtitleColor)
|
||
.lineLimit(messageLimitLine)
|
||
.truncationMode(.tail)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
Spacer()
|
||
|
||
VStack(alignment: .trailing, spacing: 6) {
|
||
if let timestamp {
|
||
HStack(spacing: 4) {
|
||
if shouldShowReadStatus {
|
||
Image(systemName: readStatusIconName)
|
||
.foregroundColor(readStatusColor)
|
||
.font(.caption2)
|
||
}
|
||
|
||
Text(timestamp)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
|
||
if chat.unreadCount > 0 {
|
||
Text("\(chat.unreadCount)")
|
||
.font(.caption2.bold())
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(
|
||
Capsule().fill(Color.accentColor)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
private static func formattedTimestamp(for date: Date) -> String {
|
||
let calendar = Calendar.current
|
||
let locale = Locale.current
|
||
|
||
let now = Date()
|
||
let startOfNow = calendar.startOfDay(for: now)
|
||
let startOfDate = calendar.startOfDay(for: date)
|
||
let diff = calendar.dateComponents([.day], from: startOfDate, to: startOfNow).day ?? 0
|
||
|
||
if diff <= 0 {
|
||
return timeString(for: date, locale: locale)
|
||
}
|
||
|
||
if diff == 1 {
|
||
return locale.identifier.lowercased().hasPrefix("ru") ? "Вчера" : "Yesterday"
|
||
}
|
||
|
||
if diff < 7 {
|
||
return weekdayFormatter(for: locale).string(from: date)
|
||
}
|
||
|
||
if diff < 365 {
|
||
return monthDayFormatter(for: locale).string(from: date)
|
||
}
|
||
|
||
return fullDateFormatter(for: locale).string(from: date)
|
||
}
|
||
|
||
private static func timeString(for date: Date, locale: Locale) -> String {
|
||
let timeFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: locale) ?? ""
|
||
let is12Hour = timeFormat.contains("a") || timeFormat.contains("h") || timeFormat.contains("K")
|
||
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
if is12Hour {
|
||
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "h:mm a", options: 0, locale: locale) ?? "h:mm a"
|
||
} else {
|
||
formatter.dateFormat = "HH:mm"
|
||
}
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private static func weekdayFormatter(for locale: Locale) -> DateFormatter {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
formatter.setLocalizedDateFormatFromTemplate("EEE")
|
||
return formatter
|
||
}
|
||
|
||
private static func monthDayFormatter(for locale: Locale) -> DateFormatter {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
formatter.setLocalizedDateFormatFromTemplate("MMM d")
|
||
return formatter
|
||
}
|
||
|
||
private static func fullDateFormatter(for locale: Locale) -> DateFormatter {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = locale
|
||
formatter.dateFormat = "dd.MM.yy"
|
||
return formatter
|
||
}
|
||
}
|
||
|
||
struct ChatsTab_Previews: PreviewProvider {
|
||
struct Wrapper: View {
|
||
@State private var progress: CGFloat = 1
|
||
@State private var searchText: String = ""
|
||
@StateObject private var loginViewModel = LoginViewModel()
|
||
@State private var pendingNavigation: IncomingMessageCenter.ChatNavigationTarget?
|
||
|
||
var body: some View {
|
||
ChatsTab(
|
||
loginViewModel: loginViewModel,
|
||
pendingNavigation: $pendingNavigation,
|
||
searchRevealProgress: $progress,
|
||
searchText: $searchText
|
||
)
|
||
.environmentObject(ThemeManager())
|
||
}
|
||
}
|
||
|
||
static var previews: some View {
|
||
Wrapper()
|
||
}
|
||
}
|
||
|
||
extension Notification.Name {
|
||
static let debugRefreshChats = Notification.Name("debugRefreshChats")
|
||
static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh")
|
||
static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted")
|
||
}
|