diff --git a/yobble/Network/APIModels.swift b/yobble/Network/APIModels.swift index 7c9a0ae..2c935ca 100644 --- a/yobble/Network/APIModels.swift +++ b/yobble/Network/APIModels.swift @@ -29,3 +29,16 @@ struct ErrorResponse: Decodable { struct MessagePayload: Decodable { let message: String } + +struct BlockedUserInfo: Decodable { + let userId: UUID + let login: String + let fullName: String? + let customName: String? + let createdAt: Date +} + +struct BlockedUsersPayload: Decodable { + let hasMore: Bool + let items: [BlockedUserInfo] +} diff --git a/yobble/Network/BlockedUsersService.swift b/yobble/Network/BlockedUsersService.swift index 1875644..9c69a7e 100644 --- a/yobble/Network/BlockedUsersService.swift +++ b/yobble/Network/BlockedUsersService.swift @@ -19,14 +19,6 @@ enum BlockedUsersServiceError: LocalizedError { } } -struct BlockedUserPayload: Decodable { - let userId: UUID - let login: String - let fullName: String? - let customName: String? - let createdAt: Date -} - final class BlockedUsersService { private let client: NetworkClient private let decoder: JSONDecoder @@ -38,16 +30,22 @@ final class BlockedUsersService { self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) } - func fetchBlockedUsers(completion: @escaping (Result<[BlockedUserPayload], Error>) -> Void) { + func fetchBlockedUsers(limit: Int, offset: Int, completion: @escaping (Result) -> Void) { + let query = [ + "limit": String(limit), + "offset": String(offset) + ] + client.request( path: "/v1/user/blacklist/list", method: .get, + query: query, requiresAuth: true ) { [decoder] result in switch result { case .success(let response): do { - let apiResponse = try decoder.decode(APIResponse<[BlockedUserPayload]>.self, from: response.data) + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) guard apiResponse.status == "fine" else { let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить список.", comment: "Blocked users service unexpected status") completion(.failure(BlockedUsersServiceError.unexpectedStatus(message))) @@ -73,9 +71,9 @@ final class BlockedUsersService { } } - func fetchBlockedUsers() async throws -> [BlockedUserPayload] { + func fetchBlockedUsers(limit: Int, offset: Int) async throws -> BlockedUsersPayload { try await withCheckedThrowingContinuation { continuation in - fetchBlockedUsers { result in + fetchBlockedUsers(limit: limit, offset: offset) { result in continuation.resume(with: result) } } diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 982b03a..9fec7a2 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -118,6 +118,9 @@ }, "Home" : { + }, + "Login must not end with 'bot' for non-bot accounts" : { + }, "OK" : { "comment" : "Common OK\nProfile update alert button\nОбщий текст кнопки OK", @@ -509,9 +512,6 @@ }, "Заблокировать контакт" : { "comment" : "Contacts context action block" - }, - "Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия" : { - }, "Завершить" : { "comment" : "Кнопка завершения конкретной сессии\nПодтверждение завершения других сессий\nПодтверждение завершения конкретной сессии" diff --git a/yobble/Views/Tab/Settings/BlockedUsersView.swift b/yobble/Views/Tab/Settings/BlockedUsersView.swift index d7fe8c5..b818210 100644 --- a/yobble/Views/Tab/Settings/BlockedUsersView.swift +++ b/yobble/Views/Tab/Settings/BlockedUsersView.swift @@ -3,6 +3,8 @@ import SwiftUI struct BlockedUsersView: View { @State private var blockedUsers: [BlockedUser] = [] @State private var isLoading = false + @State private var hasMore = true + @State private var offset = 0 @State private var loadError: String? @State private var pendingUnblock: BlockedUser? @State private var showUnblockConfirmation = false @@ -10,49 +12,20 @@ struct BlockedUsersView: View { @State private var activeAlert: ActiveAlert? private let blockedUsersService = BlockedUsersService() + private let limit = 20 var body: some View { List { if isLoading && blockedUsers.isEmpty { - loadingState + initialLoadingState } else if let loadError, blockedUsers.isEmpty { errorState(loadError) } else if blockedUsers.isEmpty { emptyState } else { - Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) { - ForEach(blockedUsers) { user in - HStack(spacing: 12) { - Circle() - .fill(Color.accentColor.opacity(0.15)) - .frame(width: 44, height: 44) - .overlay( - Text(user.initials) - .font(.headline) - .foregroundColor(.accentColor) - ) - VStack(alignment: .leading, spacing: 4) { - Text(user.displayName) - .font(.body) - if let handle = user.handle { - Text(handle) - .font(.caption) - .foregroundColor(.secondary) - } - } - Spacer() - } - .padding(.vertical, 4) - .swipeActions(edge: .trailing) { - Button(role: .destructive) { - pendingUnblock = user - showUnblockConfirmation = true - } label: { - Label(NSLocalizedString("Разблокировать", comment: ""), systemImage: "person.crop.circle.badge.xmark") - } - .disabled(removingUserIds.contains(user.id)) - } - } + usersSection + if hasMore { + loadMoreState } } } @@ -70,9 +43,6 @@ struct BlockedUsersView: View { .task { await loadBlockedUsers() } -// .refreshable { -// await loadBlockedUsers() -// } .alert(item: $activeAlert) { alert in switch alert { case .addPlaceholder: @@ -110,6 +80,43 @@ struct BlockedUsersView: View { } } + private var usersSection: some View { + Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) { + ForEach(blockedUsers) { user in + HStack(spacing: 12) { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 44, height: 44) + .overlay( + Text(user.initials) + .font(.headline) + .foregroundColor(.accentColor) + ) + VStack(alignment: .leading, spacing: 4) { + Text(user.displayName) + .font(.body) + if let handle = user.handle { + Text(handle) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + } + .padding(.vertical, 0) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + pendingUnblock = user + showUnblockConfirmation = true + } label: { + Label(NSLocalizedString("Разблокировать", comment: ""), systemImage: "person.crop.circle.badge.xmark") + } + .disabled(removingUserIds.contains(user.id)) + } + } + } + } + private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "hand.raised") @@ -118,10 +125,6 @@ struct BlockedUsersView: View { Text(NSLocalizedString("У вас нет заблокированных пользователей", comment: "")) .font(.headline) .multilineTextAlignment(.center) -// Text(NSLocalizedString("Заблокируйте аккаунт, чтобы скрыть его сообщения и взаимодействия", comment: "")) -// .font(.subheadline) -// .foregroundColor(.secondary) -// .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 32) @@ -129,13 +132,25 @@ struct BlockedUsersView: View { .listRowSeparator(.hidden) } - private var loadingState: some View { + private var initialLoadingState: some View { Section { ProgressView() .frame(maxWidth: .infinity, alignment: .center) } } + private var loadMoreState: some View { + Section { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .onAppear { + Task { + await loadBlockedUsers() + } + } + } + } + private func errorState(_ message: String) -> some View { Section { Text(message) @@ -146,19 +161,26 @@ struct BlockedUsersView: View { @MainActor private func loadBlockedUsers() async { - if isLoading { + guard !isLoading, hasMore else { return } isLoading = true - loadError = nil + if offset == 0 { + loadError = nil + } do { - let payloads = try await blockedUsersService.fetchBlockedUsers() - blockedUsers = payloads.map(BlockedUser.init) + let payload = try await blockedUsersService.fetchBlockedUsers(limit: limit, offset: offset) + blockedUsers.append(contentsOf: payload.items.map(BlockedUser.init)) + offset += payload.items.count + hasMore = payload.hasMore } catch { - loadError = error.localizedDescription - activeAlert = .error(message: error.localizedDescription) + let message = error.localizedDescription + if offset == 0 { + loadError = message + } + activeAlert = .error(message: message) if AppConfig.DEBUG { print("[BlockedUsersView] load blocked users failed: \(error)") } } @@ -211,7 +233,7 @@ private struct BlockedUser: Identifiable, Equatable { return "??" } - init(payload: BlockedUserPayload) { + init(payload: BlockedUserInfo) { self.id = payload.userId self.login = payload.login self.fullName = payload.fullName