diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 0b9bd09..9931467 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -276,9 +276,6 @@ }, "Биография" : { - }, - "Блокировка контакта \"%1$@\" появится позже." : { - "comment" : "Contacts block placeholder message" }, "Больше сообщений нет" : { "comment" : "Chat history top reached" @@ -648,11 +645,14 @@ }, "Заблокировать" : { - "comment" : "Blocked users add confirm\nBlocked users add title\nMessage profile block title" + "comment" : "Blocked users add confirm\nBlocked users add title\nContacts block confirm action\nMessage profile block title" }, "Заблокировать контакт" : { "comment" : "Contacts context action block" }, + "Заблокировать контакт?" : { + "comment" : "Contacts block confirmation title" + }, "Забыли пароль? Сбросить" : { }, @@ -811,9 +811,6 @@ "Избранные сообщения" : { "comment" : "Saved messages title" }, - "Изменение контакта \"%1$@\" появится позже." : { - "comment" : "Contacts edit placeholder message" - }, "Изменение пароля" : { "localizations" : { "en" : { @@ -957,7 +954,7 @@ } }, "Контакт \"%1$@\" будет удалён из списка." : { - "comment" : "Contact delete confirmation message" + "comment" : "Contact delete confirmation message\nContacts delete confirmation message" }, "Контактов пока нет" : { "comment" : "Contacts empty state title" @@ -2241,6 +2238,9 @@ } } }, + "Пользователь \"%1$@\" будет добавлен в чёрный список." : { + "comment" : "Contacts block confirmation message" + }, "Пользователь \"%1$@\" будет удалён из списка заблокированных." : { "comment" : "Unblock confirmation message" }, @@ -2792,7 +2792,7 @@ "comment" : "Кнопка копирования кода восстановления" }, "Скоро" : { - "comment" : "Contacts placeholder title\nЗаголовок заглушки" + "comment" : "Заголовок заглушки" }, "Скоро можно будет искать сообщения, ссылки и файлы в этом чате." : { "comment" : "Message profile search action description" @@ -3056,9 +3056,6 @@ "Удаление аватара пока недоступно." : { "comment" : "Avatar delete placeholder" }, - "Удаление контакта \"%1$@\" появится позже." : { - "comment" : "Contacts delete placeholder message" - }, "Удаление контакта появится позже." : { "comment" : "Contact edit delete placeholder message", "extractionState" : "stale", @@ -3072,7 +3069,7 @@ } }, "Удалить" : { - "comment" : "Contact delete confirm action" + "comment" : "Contact delete confirm action\nContacts delete confirm action" }, "Удалить из заблокированных?" : { "comment" : "Unblock confirmation title" @@ -3081,7 +3078,7 @@ "comment" : "Contact edit delete action\nContacts context action delete" }, "Удалить контакт?" : { - "comment" : "Contact delete confirmation title" + "comment" : "Contact delete confirmation title\nContacts delete confirmation title" }, "Удалить фото" : { "comment" : "Avatar delete" diff --git a/yobble/Views/Tab/ContactsTab.swift b/yobble/Views/Tab/ContactsTab.swift index e9829a5..89df281 100644 --- a/yobble/Views/Tab/ContactsTab.swift +++ b/yobble/Views/Tab/ContactsTab.swift @@ -16,10 +16,18 @@ struct ContactsTab: View { @State private var contactAvatars: [UUID: AvatarInfo] = [:] @State private var avatarLoadedIds: Set = [] @State private var avatarLoadingIds: Set = [] + @State private var contactToEdit: Contact? + @State private var contactPendingBlock: Contact? + @State private var contactPendingDelete: Contact? + @State private var showBlockConfirmation = false + @State private var showDeleteConfirmation = false + @State private var blockingContactIds: Set = [] + @State private var deletingContactIds: Set = [] private let contactsService = ContactsService() private let chatService = ChatService() private let profileService = ProfileService() + private let blockedUsersService = BlockedUsersService() private let pageSize = 25 private var currentUserId: String? { @@ -50,13 +58,12 @@ struct ContactsTab: View { contact: contact, avatarInfo: contactAvatars[contact.id], currentUserId: currentUserId, - isLoading: creatingChatForContactId == contact.id + isLoading: isRowBusy(contact) ) .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(creatingChatForContactId == contact.id) -// .disabled(contact.isDeleted || creatingChatForContactId == contact.id) + .disabled(isRowBusy(contact)) .contextMenu { Button { handleContactAction(.edit, for: contact) @@ -86,7 +93,6 @@ struct ContactsTab: View { systemImage: "trash" ) } -// .disabled(contact.isDeleted) } .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) .onAppear { @@ -128,6 +134,59 @@ struct ContactsTab: View { ) } } + .sheet(item: $contactToEdit) { contact in + NavigationView { + ContactEditView( + contact: contactEditInfo(for: contact), + onContactDeleted: { + handleContactRemoved(contact.id) + }, + onContactUpdated: { newName in + handleContactRenamed(contact.id, newName: newName) + } + ) + } + } + .confirmationDialog( + NSLocalizedString("Заблокировать контакт?", comment: "Contacts block confirmation title"), + isPresented: $showBlockConfirmation, + presenting: contactPendingBlock + ) { contact in + Button(NSLocalizedString("Заблокировать", comment: "Contacts block confirm action"), role: .destructive) { + showBlockConfirmation = false + contactPendingBlock = nil + performBlockContact(contact) + } + Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) { + showBlockConfirmation = false + contactPendingBlock = nil + } + } message: { contact in + Text(String( + format: NSLocalizedString("Пользователь \"%1$@\" будет добавлен в чёрный список.", comment: "Contacts block confirmation message"), + contact.displayName + )) + } + .confirmationDialog( + NSLocalizedString("Удалить контакт?", comment: "Contacts delete confirmation title"), + isPresented: $showDeleteConfirmation, + presenting: contactPendingDelete + ) { contact in + Button(NSLocalizedString("Удалить", comment: "Contacts delete confirm action"), role: .destructive) { + showDeleteConfirmation = false + contactPendingDelete = nil + performDeleteContact(contact) + } + Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) { + showDeleteConfirmation = false + contactPendingDelete = nil + } + } message: { contact in + Text(String( + format: NSLocalizedString("Контакт \"%1$@\" будет удалён из списка.", comment: "Contacts delete confirmation message"), + contact.displayName + )) + } .overlay(pendingChatNavigationLink) } @@ -243,10 +302,17 @@ struct ContactsTab: View { } private func handleContactAction(_ action: ContactAction, for contact: Contact) { - activeAlert = .info( - title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"), - message: action.placeholderMessage(for: contact) - ) + guard !isRowBusy(contact) else { return } + switch action { + case .edit: + contactToEdit = contact + case .block: + contactPendingBlock = contact + showBlockConfirmation = true + case .delete: + contactPendingDelete = contact + showDeleteConfirmation = true + } } private var pendingChatNavigationLink: some View { @@ -381,6 +447,92 @@ struct ContactsTab: View { } } } + + private func performBlockContact(_ contact: Contact) { + let contactId = contact.id + guard !blockingContactIds.contains(contactId) else { return } + blockingContactIds.insert(contactId) + + Task { + do { + _ = try await blockedUsersService.add(userId: contactId) + await MainActor.run { + blockingContactIds.remove(contactId) + handleContactRemoved(contactId) + } + } catch { + await MainActor.run { + blockingContactIds.remove(contactId) + activeAlert = .error(message: error.localizedDescription) + } + } + } + } + + private func performDeleteContact(_ contact: Contact) { + let contactId = contact.id + guard !deletingContactIds.contains(contactId) else { return } + deletingContactIds.insert(contactId) + + Task { + do { + try await contactsService.removeContact(userId: contactId) + await MainActor.run { + deletingContactIds.remove(contactId) + handleContactRemoved(contactId) + } + } catch { + await MainActor.run { + deletingContactIds.remove(contactId) + activeAlert = .error(message: error.localizedDescription) + } + } + } + } + + private func contactEditInfo(for contact: Contact) -> ContactEditInfo { + ContactEditInfo( + userId: contact.id, + login: contact.login, + fullName: contact.fullName, + customName: contact.customName, + avatarFileId: contactAvatars[contact.id]?.fileId + ) + } + + private func handleContactRenamed(_ contactId: UUID, newName: String) { + guard let index = contacts.firstIndex(where: { $0.id == contactId }) else { return } + contacts[index] = contacts[index].updatingCustomName(newName) + } + + private func handleContactRemoved(_ contactId: UUID) { + contacts.removeAll { $0.id == contactId } + contactAvatars.removeValue(forKey: contactId) + avatarLoadedIds.remove(contactId) + avatarLoadingIds.remove(contactId) + if creatingChatForContactId == contactId { + creatingChatForContactId = nil + } + blockingContactIds.remove(contactId) + deletingContactIds.remove(contactId) + if contactToEdit?.id == contactId { + contactToEdit = nil + } + if contactPendingBlock?.id == contactId { + contactPendingBlock = nil + showBlockConfirmation = false + } + if contactPendingDelete?.id == contactId { + contactPendingDelete = nil + showDeleteConfirmation = false + } + } + + private func isRowBusy(_ contact: Contact) -> Bool { + creatingChatForContactId == contact.id + || blockingContactIds.contains(contact.id) + || deletingContactIds.contains(contact.id) + } } private struct ContactRow: View { @@ -575,6 +727,20 @@ private struct Contact: Identifiable, Equatable { } } + func updatingCustomName(_ newName: String) -> Contact { + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + let updatedCustomName = trimmed.isEmpty ? nil : trimmed + let payload = ContactPayload( + userId: id, + login: login, + fullName: fullName, + customName: updatedCustomName, + friendCode: friendCode, + createdAt: createdAt + ) + return Contact(payload: payload) + } + private static let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short @@ -598,24 +764,4 @@ private enum ContactAction { case edit case block case delete - - func placeholderMessage(for contact: Contact) -> String { - switch self { - case .edit: - return String( - format: NSLocalizedString("Изменение контакта \"%1$@\" появится позже.", comment: "Contacts edit placeholder message"), - contact.displayName - ) - case .block: - return String( - format: NSLocalizedString("Блокировка контакта \"%1$@\" появится позже.", comment: "Contacts block placeholder message"), - contact.displayName - ) - case .delete: - return String( - format: NSLocalizedString("Удаление контакта \"%1$@\" появится позже.", comment: "Contacts delete placeholder message"), - contact.displayName - ) - } - } }