Compare commits

..

2 Commits

Author SHA1 Message Date
43a5d8193d add confirm while delete 2025-10-23 22:29:23 +03:00
6b81860960 add blocked user list 2025-10-23 22:23:20 +03:00
3 changed files with 307 additions and 12 deletions

View File

@ -0,0 +1,172 @@
import Foundation
enum BlockedUsersServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
var errorDescription: String? {
switch self {
case .unexpectedStatus(let message):
return message
case .decoding(let debugDescription):
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось загрузить список.", comment: "Blocked users service decoding error")
}
}
}
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
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
}
func fetchBlockedUsers(completion: @escaping (Result<[BlockedUserPayload], Error>) -> Void) {
client.request(
path: "/v1/user/blacklist/list",
method: .get,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<[BlockedUserPayload]>.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)))
return
}
completion(.success(apiResponse.data))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[BlockedUsersService] decode blocked users failed: \(debugMessage)")
}
completion(.failure(BlockedUsersServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(BlockedUsersServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func fetchBlockedUsers() async throws -> [BlockedUserPayload] {
try await withCheckedThrowingContinuation { continuation in
fetchBlockedUsers { result in
continuation.resume(with: result)
}
}
}
private static func decodeDate(from decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = iso8601WithFractionalSeconds.date(from: string) {
return date
}
if let date = iso8601Simple.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Невозможно декодировать дату: \(string)"
)
}
private static func describeDecodingError(error: Error, data: Data) -> String {
var parts: [String] = []
if let decodingError = error as? DecodingError {
parts.append(decodingDescription(from: decodingError))
} else {
parts.append(error.localizedDescription)
}
if let payload = truncatedPayload(from: data) {
parts.append("payload=\(payload)")
}
return parts.joined(separator: "\n")
}
private static func decodingDescription(from error: DecodingError) -> String {
switch error {
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .valueNotFound(let type, let context):
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
case .dataCorrupted(let context):
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}
private static func codingPath(from context: DecodingError.Context) -> String {
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
return path.isEmpty ? "root" : path.joined(separator: ".")
}
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else {
return nil
}
if string.count <= limit {
return string
}
let index = string.index(string.startIndex, offsetBy: limit)
return String(string[string.startIndex..<index]) + ""
}
private static func errorMessage(from data: Data) -> String? {
if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
if let detail = apiError.detail, !detail.isEmpty {
return detail
}
if let message = apiError.data?.message, !message.isEmpty {
return message
}
}
if let string = String(data: data, encoding: .utf8), !string.isEmpty {
return string
}
return nil
}
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let iso8601Simple: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}

View File

@ -102,7 +102,7 @@
} }
}, },
"OK" : { "OK" : {
"comment" : "Profile update alert button", "comment" : "Common OK\nProfile update alert button",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -386,6 +386,9 @@
} }
} }
}, },
"Добавление новых блокировок появится позже." : {
"comment" : "Add blocked user placeholder message"
},
"Другое" : { "Другое" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -930,6 +933,9 @@
} }
} }
}, },
"Не удалось загрузить список." : {
"comment" : "Blocked users service decoding error\nBlocked users service unexpected status"
},
"Не удалось загрузить текст правил." : { "Не удалось загрузить текст правил." : {
}, },
@ -1265,6 +1271,9 @@
}, },
"Открыть правила" : { "Открыть правила" : {
},
"Отмена" : {
"comment" : "Common cancel"
}, },
"Отображаемое имя" : { "Отображаемое имя" : {
@ -1606,6 +1615,9 @@
} }
} }
}, },
"Пользователь \"%1$@\" будет удалён из списка заблокированных." : {
"comment" : "Unblock confirmation message"
},
"Пользователь Системы 1" : { "Пользователь Системы 1" : {
"comment" : "Тестовая подмена офф аккаунта", "comment" : "Тестовая подмена офф аккаунта",
"extractionState" : "manual", "extractionState" : "manual",
@ -1825,7 +1837,7 @@
}, },
"Разблокировать" : { "Разблокировать" : {
"comment" : "Unblock confirmation action"
}, },
"Разрешить пересылку сообщений" : { "Разрешить пересылку сообщений" : {
"localizations" : { "localizations" : {
@ -2024,6 +2036,9 @@
"Скопировать" : { "Скопировать" : {
"comment" : "Search placeholder copy" "comment" : "Search placeholder copy"
}, },
"Скоро" : {
"comment" : "Add blocked user placeholder title"
},
"Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : { "Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : {
"comment" : "Concept tab placeholder description" "comment" : "Concept tab placeholder description"
}, },
@ -2203,6 +2218,9 @@
} }
} }
}, },
"Удалить из заблокированных?" : {
"comment" : "Unblock confirmation title"
},
"Удалить чат (скоро)" : { "Удалить чат (скоро)" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@ -2,10 +2,21 @@ import SwiftUI
struct BlockedUsersView: View { struct BlockedUsersView: View {
@State private var blockedUsers: [BlockedUser] = [] @State private var blockedUsers: [BlockedUser] = []
@State private var isLoading = false
@State private var loadError: String?
@State private var showAddBlockedUserAlert = false
@State private var pendingUnblock: BlockedUser?
@State private var showUnblockConfirmation = false
private let blockedUsersService = BlockedUsersService()
var body: some View { var body: some View {
List { List {
if blockedUsers.isEmpty { if isLoading && blockedUsers.isEmpty {
loadingState
} else if let loadError, blockedUsers.isEmpty {
errorState(loadError)
} else if blockedUsers.isEmpty {
emptyState emptyState
} else { } else {
Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) { Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) {
@ -29,23 +40,57 @@ struct BlockedUsersView: View {
} }
} }
Spacer() Spacer()
Button(role: .destructive) {
unblock(user)
} label: {
Text(NSLocalizedString("Разблокировать", comment: ""))
}
.buttonStyle(.borderless)
} }
.padding(.vertical, 4) .padding(.vertical, 4)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
pendingUnblock = user
showUnblockConfirmation = true
} label: {
Label(NSLocalizedString("Разблокировать", comment: ""), systemImage: "person.crop.circle.badge.xmark")
}
}
} }
} }
} }
} }
.navigationTitle(NSLocalizedString("Заблокированные", comment: "")) .navigationTitle(NSLocalizedString("Заблокированные", comment: ""))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showAddBlockedUserAlert = true
} label: {
Image(systemName: "plus")
}
}
}
.task { .task {
await loadBlockedUsers() await loadBlockedUsers()
} }
.refreshable {
await loadBlockedUsers()
}
.alert(NSLocalizedString("Скоро", comment: "Add blocked user placeholder title"), isPresented: $showAddBlockedUserAlert) {
Button(NSLocalizedString("OK", comment: "Common OK"), role: .cancel) {}
} message: {
Text(NSLocalizedString("Добавление новых блокировок появится позже.", comment: "Add blocked user placeholder message"))
}
.confirmationDialog(
NSLocalizedString("Удалить из заблокированных?", comment: "Unblock confirmation title"),
isPresented: $showUnblockConfirmation,
presenting: pendingUnblock
) { user in
Button(NSLocalizedString("Разблокировать", comment: "Unblock confirmation action"), role: .destructive) {
unblock(user)
pendingUnblock = nil
}
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
pendingUnblock = nil
}
} message: { user in
Text(String(format: NSLocalizedString("Пользователь \"%1$@\" будет удалён из списка заблокированных.", comment: "Unblock confirmation message"), user.displayName))
}
} }
private var emptyState: some View { private var emptyState: some View {
@ -67,8 +112,41 @@ struct BlockedUsersView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
} }
private var loadingState: some View {
Section {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func errorState(_ message: String) -> some View {
Section {
Text(message)
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .center)
}
}
@MainActor
private func loadBlockedUsers() async { private func loadBlockedUsers() async {
// TODO: integrate with real data source once available if isLoading {
return
}
isLoading = true
loadError = nil
do {
let payloads = try await blockedUsersService.fetchBlockedUsers()
blockedUsers = payloads.map(BlockedUser.init)
} catch {
loadError = error.localizedDescription
if AppConfig.DEBUG {
print("[BlockedUsersView] load blocked users failed: \(error)")
}
}
isLoading = false
} }
private func unblock(_ user: BlockedUser) { private func unblock(_ user: BlockedUser) {
@ -79,8 +157,13 @@ struct BlockedUsersView: View {
private struct BlockedUser: Identifiable, Equatable { private struct BlockedUser: Identifiable, Equatable {
let id: UUID let id: UUID
let displayName: String let login: String
let handle: String? let fullName: String?
let customName: String?
let createdAt: Date
private(set) var displayName: String
private(set) var handle: String?
var initials: String { var initials: String {
let components = displayName.split(separator: " ") let components = displayName.split(separator: " ")
@ -100,4 +183,26 @@ private struct BlockedUser: Identifiable, Equatable {
return "??" return "??"
} }
init(payload: BlockedUserPayload) {
self.id = payload.userId
self.login = payload.login
self.fullName = payload.fullName
self.customName = payload.customName
self.createdAt = payload.createdAt
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = customName
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.displayName = fullName
} else {
self.displayName = payload.login
}
if !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.handle = "@\(payload.login)"
} else {
self.handle = nil
}
}
} }