Compare commits

..

3 Commits

Author SHA1 Message Date
fb8413e68c add edit privacy 2025-10-08 02:09:44 +03:00
c96fe4991d patch privacy 2025-10-08 01:57:40 +03:00
3b860a5146 add load privacy settings from server 2025-10-08 01:50:20 +03:00
4 changed files with 602 additions and 77 deletions

View File

@ -0,0 +1,117 @@
import Foundation
struct ProfileDataPayload: Decodable {
let userId: UUID
let login: String
let fullName: String?
let bio: String?
let balances: [WalletBalancePayload]
let createdAt: Date?
let stories: [JSONValue]
let profilePermissions: ProfilePermissionsPayload
private enum CodingKeys: String, CodingKey {
case userId
case login
case fullName
case bio
case balances
case createdAt
case stories
case profilePermissions
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.userId = try container.decode(UUID.self, forKey: .userId)
self.login = try container.decode(String.self, forKey: .login)
self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName)
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? []
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions)
}
}
struct WalletBalancePayload: Decodable {
let currency: String
let balance: Decimal
let displayBalance: Double?
private enum CodingKeys: String, CodingKey {
case currency
case balance
case displayBalance
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.currency = try container.decode(String.self, forKey: .currency)
self.balance = try Self.decodeDecimal(from: container, forKey: .balance)
if let doubleValue = try? container.decode(Double.self, forKey: .displayBalance) {
self.displayBalance = doubleValue
} else if let stringValue = try? container.decode(String.self, forKey: .displayBalance),
let doubleValue = Double(stringValue) {
self.displayBalance = doubleValue
} else {
self.displayBalance = nil
}
}
private static func decodeDecimal(from container: KeyedDecodingContainer<CodingKeys>, forKey key: CodingKeys) throws -> Decimal {
if let decimalValue = try? container.decode(Decimal.self, forKey: key) {
return decimalValue
}
if let stringValue = try? container.decode(String.self, forKey: key),
let decimal = Decimal(string: stringValue) {
return decimal
}
if let doubleValue = try? container.decode(Double.self, forKey: key) {
return Decimal(doubleValue)
}
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "Unable to decode decimal value for key \(key)"
)
}
}
struct ProfilePermissionsPayload: Decodable {
let isSearchable: Bool
let allowMessageForwarding: Bool
let allowMessagesFromNonContacts: Bool
let showProfilePhotoToNonContacts: Bool
let lastSeenVisibility: Int
let showBioToNonContacts: Bool
let showStoriesToNonContacts: Bool
let allowServerChats: Bool
let publicInvitePermission: Int
let groupInvitePermission: Int
let callPermission: Int
let forceAutoDeleteMessagesInPrivate: Bool
let maxMessageAutoDeleteSeconds: Int?
let autoDeleteAfterDays: Int?
}
struct ProfilePermissionsRequestPayload: Encodable {
let isSearchable: Bool
let allowMessageForwarding: Bool
let allowMessagesFromNonContacts: Bool
let showProfilePhotoToNonContacts: Bool
let lastSeenVisibility: Int
let showBioToNonContacts: Bool
let showStoriesToNonContacts: Bool
let allowServerChats: Bool
let publicInvitePermission: Int
let groupInvitePermission: Int
let callPermission: Int
let forceAutoDeleteMessagesInPrivate: Bool
let maxMessageAutoDeleteSeconds: Int?
let autoDeleteAfterDays: Int?
}
struct ProfileUpdateRequestPayload: Encodable {
let profilePermissions: ProfilePermissionsRequestPayload
}

View File

@ -0,0 +1,227 @@
import Foundation
enum ProfileServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
case encoding(String)
var errorDescription: String? {
switch self {
case .unexpectedStatus(let message):
return message
case .decoding(let debugDescription):
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile service decoding error")
case .encoding(let message):
return message
}
}
}
final class ProfileService {
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 fetchMyProfile(completion: @escaping (Result<ProfileDataPayload, Error>) -> Void) {
client.request(
path: "/v1/profile/me",
method: .get,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<ProfileDataPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile unexpected status")
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ProfileService] decode profile failed: \(debugMessage)")
}
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func fetchMyProfile() async throws -> ProfileDataPayload {
try await withCheckedThrowingContinuation { continuation in
fetchMyProfile { result in
continuation.resume(with: result)
}
}
}
func updateProfile(_ payload: ProfileUpdateRequestPayload, completion: @escaping (Result<String, Error>) -> Void) {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
guard let body = try? encoder.encode(payload) else {
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Profile update encoding error")
completion(.failure(ProfileServiceError.encoding(message)))
return
}
client.request(
path: "/v1/profile/edit",
method: .put,
body: body,
requiresAuth: true
) { result in
switch result {
case .success(let response):
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось сохранить изменения профиля.", comment: "Profile update unexpected status")
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data.message))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ProfileService] decode update response failed: \(debugMessage)")
}
if AppConfig.DEBUG {
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
} else {
let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Profile update decode error")
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
}
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func updateProfile(_ payload: ProfileUpdateRequestPayload) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
updateProfile(payload) { 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

@ -74,6 +74,7 @@
} }
}, },
"OK" : { "OK" : {
"comment" : "Profile update alert button",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -219,6 +220,9 @@
"Глобальный поиск" : { "Глобальный поиск" : {
"comment" : "Global search section" "comment" : "Global search section"
}, },
"Готово" : {
"comment" : "Profile update success title"
},
"Данные" : { "Данные" : {
}, },
@ -570,10 +574,16 @@
}, },
"Настройки приватности" : { "Настройки приватности" : {
},
"Настройки приватности обновлены." : {
"comment" : "Profile update success fallback"
}, },
"Не удалось выполнить поиск." : { "Не удалось выполнить поиск." : {
"comment" : "Search error fallback\nSearch service decoding error" "comment" : "Search error fallback\nSearch service decoding error"
}, },
"Не удалось загрузить профиль." : {
"comment" : "Profile service decoding error\nProfile unexpected status"
},
"Не удалось загрузить список чатов." : { "Не удалось загрузить список чатов." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -618,6 +628,7 @@
} }
}, },
"Не удалось обработать ответ сервера." : { "Не удалось обработать ответ сервера." : {
"comment" : "Profile update decode error",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -627,6 +638,9 @@
} }
} }
}, },
"Не удалось подготовить данные запроса." : {
"comment" : "Profile update encoding error"
},
"Не удалось сериализовать данные запроса." : { "Не удалось сериализовать данные запроса." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -637,6 +651,9 @@
} }
} }
}, },
"Не удалось сохранить изменения профиля." : {
"comment" : "Profile update unexpected status"
},
"Неверный запрос (400)." : { "Неверный запрос (400)." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -820,6 +837,7 @@
}, },
"Ошибка" : { "Ошибка" : {
"comment" : "Profile update error title",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {

View File

@ -1,7 +1,13 @@
import SwiftUI import SwiftUI
struct EditPrivacyView: View { struct EditPrivacyView: View {
@State private var profilePermissions = ProfilePermissionsResponse() @State private var profilePermissions = ProfilePermissionsState()
@State private var isLoading = false
@State private var loadError: String?
@State private var isSaving = false
@State private var alertData: AlertData?
private let profileService = ProfileService()
private var privacyScopeOptions: [PrivacyScope] { PrivacyScope.allCases } private var privacyScopeOptions: [PrivacyScope] { PrivacyScope.allCases }
@ -30,85 +36,121 @@ struct EditPrivacyView: View {
var body: some View { var body: some View {
Form { Form {
Section(header: Text("Профиль и поиск")) { if isLoading {
Toggle("Разрешить поиск профиля", isOn: $profilePermissions.isSearchable) Section {
Toggle("Разрешить пересылку сообщений", isOn: $profilePermissions.allowMessageForwarding) ProgressView()
Toggle("Принимать сообщения от незнакомцев", isOn: $profilePermissions.allowMessagesFromNonContacts) .frame(maxWidth: .infinity, alignment: .center)
}
Section(header: Text("Видимость и контент")) {
Toggle("Показывать фото не-контактам", isOn: $profilePermissions.showProfilePhotoToNonContacts)
Toggle("Показывать био не-контактам", isOn: $profilePermissions.showBioToNonContacts)
Toggle("Показывать сторисы не-контактам", isOn: $profilePermissions.showStoriesToNonContacts)
Picker("Видимость статуса 'был в сети'", selection: $profilePermissions.lastSeenVisibility) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
} }
.pickerStyle(.segmented) } else if let loadError {
} Section {
Text(loadError)
Section(header: Text("Приглашения и звонки")) { .foregroundColor(.red)
Picker("Кто может приглашать в паблики", selection: $profilePermissions.publicInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
Picker("Кто может приглашать в беседы", selection: $profilePermissions.groupInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
Picker("Кто может звонить", selection: $profilePermissions.callPermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
}
Section(header: Text("Чаты и хранение")) {
Toggle("Разрешить хранить чаты на сервере (Обычный)", isOn: $profilePermissions.allowServerChats)
Toggle("Принудительное автоудаление в ЛС (Приватный)", isOn: $profilePermissions.forceAutoDeleteMessagesInPrivate)
if profilePermissions.forceAutoDeleteMessagesInPrivate {
Stepper(value: forceAutoDeleteBinding, in: 5...86400, step: 5) {
Text("Таймер автоудаления: \(formattedAutoDeleteSeconds(forceAutoDeleteBinding.wrappedValue))")
}
}
}
Section(header: Text("Автоудаление аккаунта")) {
Toggle("Включить автоудаление аккаунта", isOn: autoDeleteAccountEnabled)
if autoDeleteAccountEnabled.wrappedValue {
Stepper(value: autoDeleteAccountBinding, in: 1...365) {
Text("Удалять аккаунт через \(autoDeleteAccountBinding.wrappedValue) дн.")
}
}
}
Section {
Button("Сохранить изменения") {
print("Параметры приватности: \(profilePermissions)")
}
.frame(maxWidth: .infinity, alignment: .center)
}
Section {
Button(role: .destructive) {
profilePermissions = ProfilePermissionsResponse()
print("Настройки приватности сброшены к значениям по умолчанию")
} label: {
Text("Сбросить по умолчанию")
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
} }
if !isLoading && loadError == nil {
Section(header: Text("Профиль и поиск")) {
Toggle("Разрешить поиск профиля", isOn: $profilePermissions.isSearchable)
Toggle("Разрешить пересылку сообщений", isOn: $profilePermissions.allowMessageForwarding)
Toggle("Принимать сообщения от незнакомцев", isOn: $profilePermissions.allowMessagesFromNonContacts)
}
Section(header: Text("Видимость и контент")) {
Toggle("Показывать фото не-контактам", isOn: $profilePermissions.showProfilePhotoToNonContacts)
Toggle("Показывать био не-контактам", isOn: $profilePermissions.showBioToNonContacts)
Toggle("Показывать сторисы не-контактам", isOn: $profilePermissions.showStoriesToNonContacts)
Picker("Видимость статуса 'был в сети'", selection: $profilePermissions.lastSeenVisibility) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
}
Section(header: Text("Приглашения и звонки")) {
Picker("Кто может приглашать в паблики", selection: $profilePermissions.publicInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
Picker("Кто может приглашать в беседы", selection: $profilePermissions.groupInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
Picker("Кто может звонить", selection: $profilePermissions.callPermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
}
Section(header: Text("Чаты и хранение")) {
Toggle("Разрешить хранить чаты на сервере (Обычный)", isOn: $profilePermissions.allowServerChats)
Toggle("Принудительное автоудаление в ЛС (Приватный)", isOn: $profilePermissions.forceAutoDeleteMessagesInPrivate)
if profilePermissions.forceAutoDeleteMessagesInPrivate {
Stepper(value: forceAutoDeleteBinding, in: 5...86400, step: 5) {
Text("Таймер автоудаления: \(formattedAutoDeleteSeconds(forceAutoDeleteBinding.wrappedValue))")
}
}
}
Section(header: Text("Автоудаление аккаунта")) {
Toggle("Включить автоудаление аккаунта", isOn: autoDeleteAccountEnabled)
if autoDeleteAccountEnabled.wrappedValue {
Stepper(value: autoDeleteAccountBinding, in: 1...365) {
Text("Удалять аккаунт через \(autoDeleteAccountBinding.wrappedValue) дн.")
}
}
}
Section {
Button {
Task {
await saveProfile()
}
} label: {
if isSaving {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
} else {
Text("Сохранить изменения")
.frame(maxWidth: .infinity, alignment: .center)
}
}
.disabled(isLoading || isSaving)
}
Section {
Button(role: .destructive) {
profilePermissions = ProfilePermissionsState()
print("Настройки приватности сброшены к значениям по умолчанию")
} label: {
Text("Сбросить по умолчанию")
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
.alert(item: $alertData) { data in
Alert(
title: Text(data.kind == .success
? NSLocalizedString("Готово", comment: "Profile update success title")
: NSLocalizedString("Ошибка", comment: "Profile update error title")),
message: Text(data.message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Profile update alert button"))) {
alertData = nil
}
)
} }
.navigationTitle("Настройки приватности") .navigationTitle("Настройки приватности")
.onChange(of: profilePermissions.forceAutoDeleteMessagesInPrivate) { newValue in .onChange(of: profilePermissions.forceAutoDeleteMessagesInPrivate) { newValue in
@ -118,6 +160,9 @@ struct EditPrivacyView: View {
profilePermissions.maxMessageAutoDeleteSeconds = nil profilePermissions.maxMessageAutoDeleteSeconds = nil
} }
} }
.task {
await loadProfile()
}
} }
private func formattedAutoDeleteSeconds(_ value: Int) -> String { private func formattedAutoDeleteSeconds(_ value: Int) -> String {
@ -153,7 +198,7 @@ private enum PrivacyScope: Int, CaseIterable, Identifiable {
} }
} }
struct ProfilePermissionsResponse: Codable { struct ProfilePermissionsState: Codable, Equatable {
var isSearchable: Bool = true var isSearchable: Bool = true
var allowMessageForwarding: Bool = true var allowMessageForwarding: Bool = true
var allowMessagesFromNonContacts: Bool = true var allowMessagesFromNonContacts: Bool = true
@ -169,3 +214,121 @@ struct ProfilePermissionsResponse: Codable {
var maxMessageAutoDeleteSeconds: Int? = nil var maxMessageAutoDeleteSeconds: Int? = nil
var autoDeleteAfterDays: Int? = nil var autoDeleteAfterDays: Int? = nil
} }
extension ProfilePermissionsState {
init(payload: ProfilePermissionsPayload) {
self.isSearchable = payload.isSearchable
self.allowMessageForwarding = payload.allowMessageForwarding
self.allowMessagesFromNonContacts = payload.allowMessagesFromNonContacts
self.showProfilePhotoToNonContacts = payload.showProfilePhotoToNonContacts
self.lastSeenVisibility = payload.lastSeenVisibility
self.showBioToNonContacts = payload.showBioToNonContacts
self.showStoriesToNonContacts = payload.showStoriesToNonContacts
self.allowServerChats = payload.allowServerChats
self.publicInvitePermission = payload.publicInvitePermission
self.groupInvitePermission = payload.groupInvitePermission
self.callPermission = payload.callPermission
self.forceAutoDeleteMessagesInPrivate = payload.forceAutoDeleteMessagesInPrivate
self.maxMessageAutoDeleteSeconds = payload.maxMessageAutoDeleteSeconds
self.autoDeleteAfterDays = payload.autoDeleteAfterDays
}
}
private extension ProfilePermissionsState {
var requestPayload: ProfilePermissionsRequestPayload {
ProfilePermissionsRequestPayload(
isSearchable: isSearchable,
allowMessageForwarding: allowMessageForwarding,
allowMessagesFromNonContacts: allowMessagesFromNonContacts,
showProfilePhotoToNonContacts: showProfilePhotoToNonContacts,
lastSeenVisibility: lastSeenVisibility,
showBioToNonContacts: showBioToNonContacts,
showStoriesToNonContacts: showStoriesToNonContacts,
allowServerChats: allowServerChats,
publicInvitePermission: publicInvitePermission,
groupInvitePermission: groupInvitePermission,
callPermission: callPermission,
forceAutoDeleteMessagesInPrivate: forceAutoDeleteMessagesInPrivate,
maxMessageAutoDeleteSeconds: maxMessageAutoDeleteSeconds,
autoDeleteAfterDays: autoDeleteAfterDays
)
}
}
private extension EditPrivacyView {
func saveProfile() async {
let shouldProceed = await MainActor.run { () -> Bool in
if isSaving {
return false
}
isSaving = true
return true
}
guard shouldProceed else { return }
do {
let requestPayload = ProfileUpdateRequestPayload(profilePermissions: profilePermissions.requestPayload)
let responseMessage = try await profileService.updateProfile(requestPayload)
let fallback = NSLocalizedString("Настройки приватности обновлены.", comment: "Profile update success fallback")
let message = responseMessage.isEmpty ? fallback : responseMessage
await MainActor.run {
alertData = AlertData(kind: .success, message: message)
isSaving = false
}
} catch {
let message: String
if let error = error as? LocalizedError, let description = error.errorDescription {
message = description
} else {
message = error.localizedDescription
}
await MainActor.run {
alertData = AlertData(kind: .error, message: message)
isSaving = false
}
}
}
func loadProfile() async {
await MainActor.run {
if !isLoading {
isLoading = true
loadError = nil
}
}
do {
let profile = try await profileService.fetchMyProfile()
await MainActor.run {
profilePermissions = ProfilePermissionsState(payload: profile.profilePermissions)
isLoading = false
}
} catch {
let message: String
if let error = error as? LocalizedError, let description = error.errorDescription {
message = description
} else {
message = error.localizedDescription
}
await MainActor.run {
loadError = message
isLoading = false
}
}
}
}
private struct AlertData: Identifiable {
enum Kind {
case success
case error
}
let id = UUID()
let kind: Kind
let message: String
}