Compare commits
2 Commits
8acacdb8c1
...
43a5d8193d
| Author | SHA1 | Date | |
|---|---|---|---|
| 43a5d8193d | |||
| 6b81860960 |
172
yobble/Network/BlockedUsersService.swift
Normal file
172
yobble/Network/BlockedUsersService.swift
Normal 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
@ -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" : {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user