diff --git a/Localization/en.lproj/Localizable.strings b/Localization/en.lproj/Localizable.strings index e81d57c..10e7ef2 100644 --- a/Localization/en.lproj/Localizable.strings +++ b/Localization/en.lproj/Localizable.strings @@ -266,5 +266,6 @@ "editProfile.title.website" = "Website"; "editProfile.title.save" = "Save"; "editProfile.title.accountSaved" = "Profile has been updated."; +"editProfile.title.photoInfo" = "The changed photo will be visible in the app and on the website with a small delay."; "editProfile.error.saveAccountFailed" = "Saving profile failed."; "editProfile.error.loadingAvatarFailed" = "Loading avatar failed."; diff --git a/Localization/pl.lproj/Localizable.strings b/Localization/pl.lproj/Localizable.strings index 43fce64..cc7885c 100644 --- a/Localization/pl.lproj/Localizable.strings +++ b/Localization/pl.lproj/Localizable.strings @@ -266,5 +266,6 @@ "editProfile.title.website" = "Strona"; "editProfile.title.save" = "Zapisz"; "editProfile.title.accountSaved" = "Profil zaktualizowano."; +"editProfile.title.photoInfo" = "Zmienione zdjęcie będzie widoczne w aplikacji oraz na stronie z małym opóźnieniem."; "editProfile.error.saveAccountFailed" = "Błąd podczas aktualizacji profilu."; "editProfile.error.loadingAvatarFailed" = "Błąd podczas wczytywania zdjęcia."; diff --git a/PixelfedKit/Sources/PixelfedKit/PixelfedClient+Account.swift b/PixelfedKit/Sources/PixelfedKit/PixelfedClient+Account.swift index ee86a7a..abb1a0b 100644 --- a/PixelfedKit/Sources/PixelfedKit/PixelfedClient+Account.swift +++ b/PixelfedKit/Sources/PixelfedKit/PixelfedClient+Account.swift @@ -170,4 +170,13 @@ public extension PixelfedClientAuthenticated { return try await downloadJson(Account.self, request: request) } + + func avatar(image: Data?) async throws -> Account { + let request = try Self.request( + for: baseURL, + target: Pixelfed.Account.updateAvatar(image), + withBearerToken: token) + + return try await downloadJson(Account.self, request: request) + } } diff --git a/PixelfedKit/Sources/PixelfedKit/Targets/Accounts.swift b/PixelfedKit/Sources/PixelfedKit/Targets/Accounts.swift index 5f7908e..7d90f34 100644 --- a/PixelfedKit/Sources/PixelfedKit/Targets/Accounts.swift +++ b/PixelfedKit/Sources/PixelfedKit/Targets/Accounts.swift @@ -22,6 +22,7 @@ extension Pixelfed { case relationships([EntityId]) case search(SearchQuery, Int) case updateCredentials(String, String, String, Data?) + case updateAvatar(Data?) } } @@ -59,6 +60,8 @@ extension Pixelfed.Account: TargetType { return "\(apiPath)/search" case .updateCredentials(_, _, _, _): return "\(apiPath)/update_credentials" + case .updateAvatar(_): + return "\(apiPath)/update_credentials" } } @@ -66,8 +69,10 @@ extension Pixelfed.Account: TargetType { switch self { case .follow(_), .unfollow(_), .block(_), .unblock(_), .mute(_), .unmute(_): return .post - case .updateCredentials(_, _, _, _): - return .patch + case .updateCredentials(_, _, _, _), .updateAvatar(_): + // Mastodon API uses PATCH, however in Pixelfed we have to use POST: https://github.com/pixelfed/pixelfed/issues/4250 + // Also it seems that we have to speparatelly save text fields and avatar(?). + return .post default: return .get } @@ -113,19 +118,10 @@ extension Pixelfed.Account: TargetType { minId = _minId limit = _limit page = _page - case .updateCredentials(let displayName, let bio, let website, let image): - if image == nil { - return [ - ("display_name", displayName), - ("note", bio), - ("website", website), - ("_pe", "1") - ] - } else { - return [ - ("_pe", "1") - ] - } + case .updateCredentials(_, _, _, _), .updateAvatar(_): + return [ + ("_pe", "1") + ] case .account(_), .verifyCredentials: return [ ("_pe", "1") @@ -155,12 +151,8 @@ extension Pixelfed.Account: TargetType { public var headers: [String: String]? { switch self { - case .updateCredentials(_, _, _, let image): - if image != nil { - return ["content-type": "multipart/form-data; boundary=\(multipartBoundary)"] - } else { - return ["content-type": "application/x-www-form-urlencoded"] - } + case .updateCredentials(_, _, _, _), .updateAvatar(_): + return ["content-type": "multipart/form-data; boundary=\(multipartBoundary)"] default: return [:].contentTypeApplicationJson } @@ -169,18 +161,25 @@ extension Pixelfed.Account: TargetType { public var httpBody: Data? { switch self { case .updateCredentials(let displayName, let bio, let website, let image): - if let image { - let formDataBuilder = MultipartFormData(boundary: multipartBoundary) - - formDataBuilder.addTextField(named: "display_name", value: displayName) - formDataBuilder.addTextField(named: "note", value: bio) - formDataBuilder.addTextField(named: "website", value: website) - formDataBuilder.addDataField(named: "avatar", fileName: "avatar.jpg", data: image, mimeType: "image/jpeg") + let formDataBuilder = MultipartFormData(boundary: multipartBoundary) + + formDataBuilder.addTextField(named: "display_name", value: displayName) + formDataBuilder.addTextField(named: "note", value: bio) + formDataBuilder.addTextField(named: "website", value: website) - return formDataBuilder.build() - } else { - return nil + if let image { + formDataBuilder.addDataField(named: "avatar", fileName: "avatar.jpg", data: image, mimeType: "image/jpeg") } + + return formDataBuilder.build() + case .updateAvatar(let image): + let formDataBuilder = MultipartFormData(boundary: multipartBoundary) + + if let image { + formDataBuilder.addDataField(named: "avatar", fileName: "avatar.jpg", data: image, mimeType: "image/jpeg") + } + + return formDataBuilder.build() default: return nil } diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index abafeca..2f06078 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -1278,7 +1278,7 @@ CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageWidget/Info.plist; @@ -1306,7 +1306,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = VernissageWidget/VernissageWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = B2U9FEKYP8; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = VernissageWidget/Info.plist; @@ -1454,7 +1454,7 @@ CODE_SIGN_ENTITLEMENTS = Vernissage/Vernissage.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 81; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; @@ -1494,7 +1494,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Vernissage/Vernissage.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 81; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = "\"Vernissage/Preview Content\""; DEVELOPMENT_TEAM = B2U9FEKYP8; ENABLE_PREVIEWS = YES; diff --git a/Vernissage/EnvironmentObjects/Client+Account.swift b/Vernissage/EnvironmentObjects/Client+Account.swift index c1e335c..2b8e81b 100644 --- a/Vernissage/EnvironmentObjects/Client+Account.swift +++ b/Vernissage/EnvironmentObjects/Client+Account.swift @@ -86,5 +86,9 @@ extension Client { func update(displayName: String, bio: String, website: String, image: Data?) async throws -> Account { return try await pixelfedClient.update(displayName: displayName, bio: bio, website: website, image: image) } + + func avatar(image: Data?) async throws -> Account { + return try await pixelfedClient.avatar(image: image) + } } } diff --git a/Vernissage/Models/AccountModel.swift b/Vernissage/Models/AccountModel.swift index e6eea9c..88fe54b 100644 --- a/Vernissage/Models/AccountModel.swift +++ b/Vernissage/Models/AccountModel.swift @@ -12,7 +12,6 @@ public class AccountModel: ObservableObject, Identifiable { public let refreshToken: String? public let acct: String public let avatar: URL? - public let avatarData: Data? public let clientId: String public let clientSecret: String public let clientVapidKey: String @@ -29,6 +28,8 @@ public class AccountModel: ObservableObject, Identifiable { public let username: String public let lastSeenStatusId: String? + @Published public var avatarData: Data? + init(accountData: AccountData) { self.accessToken = accountData.accessToken self.refreshToken = accountData.refreshToken diff --git a/Vernissage/Views/EditProfileView.swift b/Vernissage/Views/EditProfileView.swift index 2ad2bf0..db35c51 100644 --- a/Vernissage/Views/EditProfileView.swift +++ b/Vernissage/Views/EditProfileView.swift @@ -22,6 +22,9 @@ struct EditProfileView: View { @State private var avatarData: Data? private let account: Account + private let bioMaxLength = 200 + private let displayNameMaxLength = 30 + private let websiteMaxLength = 120 init(account: Account) { self.account = account @@ -64,8 +67,20 @@ struct EditProfileView: View { } Text("@\(self.account.acct)") - .font(.subheadline) + .font(.headline) .foregroundColor(.lightGrayColor) + + if self.avatarData != nil { + HStack { + Image(systemName: "info.circle") + .font(.body) + .foregroundColor(.accentColor) + Text("editProfile.title.photoInfo") + .font(.footnote) + .foregroundColor(.lightGrayColor) + } + .padding(.top, 4) + } } Spacer() @@ -74,21 +89,50 @@ struct EditProfileView: View { .listRowBackground(Color(UIColor.systemGroupedBackground)) .listRowSeparator(Visibility.hidden) - - Section("editProfile.title.displayName") { + Section { TextField("", text: $displayName) + .onChange(of: self.displayName, perform: { newValue in + self.displayName = String(self.displayName.prefix(self.displayNameMaxLength)) + }) + } header: { + Text("editProfile.title.displayName", comment: "Display name") + } footer: { + HStack { + Spacer() + Text("\(self.displayName.count)/\(self.displayNameMaxLength)") + } } - Section("editProfile.title.bio") { + Section { TextField("", text: $bio, axis: .vertical) .lineLimit(5, reservesSpace: true) + .onChange(of: self.bio, perform: { newValue in + self.bio = String(self.bio.prefix(self.bioMaxLength)) + }) + } header: { + Text("editProfile.title.bio", comment: "Bio") + } footer: { + HStack { + Spacer() + Text("\(self.bio.count)/\(self.bioMaxLength)") + } } - Section("editProfile.title.website") { + Section { TextField("", text: $website) .autocapitalization(.none) .keyboardType(.URL) .autocorrectionDisabled() + .onChange(of: self.website, perform: { newValue in + self.website = String(self.website.prefix(self.websiteMaxLength)) + }) + } header: { + Text("editProfile.title.website", comment: "Website") + } footer: { + HStack { + Spacer() + Text("\(self.website.count)/\(self.websiteMaxLength)") + } } } .toolbar { @@ -123,12 +167,26 @@ struct EditProfileView: View { matching: .images) } + @MainActor private func saveProfile() async { do { - let savedAccount = try await self.client.accounts?.update(displayName: self.displayName, - bio: self.bio, - website: self.website, - image: self.avatarData) + _ = try await self.client.accounts?.update(displayName: self.displayName, + bio: self.bio, + website: self.website, + image: nil) + + if let avatarData = self.avatarData { + _ = try await self.client.accounts?.avatar(image: avatarData) + + if let accountData = AccountDataHandler.shared.getAccountData(accountId: self.account.id) { + accountData.avatarData = avatarData + self.applicationState.account?.avatarData = avatarData + CoreDataHandler.shared.save() + } + } + + let savedAccount = try await self.client.accounts?.account(withId: self.account.id) + // self.applicationState.account?.avatar, self.applicationState.updatedProfile = savedAccount ToastrService.shared.showSuccess("editProfile.title.accountSaved", imageSystemName: "person.crop.circle") @@ -162,7 +220,9 @@ struct EditProfileView: View { return } - self.avatarData = data + withAnimation(.linear) { + self.avatarData = data + } self.saveDisabled = false } catch {