diff --git a/Sources/Gravatar/Network/Services/ProfileService.swift b/Sources/Gravatar/Network/Services/ProfileService.swift index 776ec143..0f1bdf73 100644 --- a/Sources/Gravatar/Network/Services/ProfileService.swift +++ b/Sources/Gravatar/Network/Services/ProfileService.swift @@ -3,8 +3,12 @@ import Foundation private let baseURL = URL(string: "https://api.gravatar.com/v3/profiles/")! private let avatarsBaseURLComponents = URLComponents(string: "https://api.gravatar.com/v3/me/avatars")! +private func avatarBaseURL(with avatarID: String) -> URL? { + URL(string: "https://api.gravatar.com/v3/me/avatars/\(avatarID)") +} + private func selectAvatarBaseURL(with avatarID: String) -> URL? { - URL(string: "https://api.gravatar.com/v3/me/avatars/\(avatarID)/email") + avatarBaseURL(with: avatarID)?.appendingPathComponent("email") } /// A service to perform Profile related tasks. @@ -59,6 +63,30 @@ public struct ProfileService: ProfileFetching, Sendable { throw error.apiError() } } + + @discardableResult + package func setRating( + _ rating: AvatarRating, + for avatar: AvatarIdentifier, + token: String + ) async throws -> Avatar { + guard let url = avatarsBaseURLComponents.url?.appendingPathComponent(avatar.id) + else { + throw APIError.requestError(reason: .urlInitializationFailed) + } + + do { + var request = URLRequest(url: url).settingAuthorizationHeaderField(with: token) + request.httpMethod = "PATCH" + + let requestBody = try JSONEncoder().encode(UpdateAvatarRequest(rating: rating)) + request.httpBody = requestBody + let (data, _) = try await client.data(with: request) + return try data.decode() + } catch { + throw error.apiError() + } + } } extension ProfileService { diff --git a/Sources/GravatarUI/Avatar+AvatarRating.swift b/Sources/GravatarUI/Avatar+AvatarRating.swift new file mode 100644 index 00000000..489473cb --- /dev/null +++ b/Sources/GravatarUI/Avatar+AvatarRating.swift @@ -0,0 +1,13 @@ +extension Avatar { + /// Transforms `Avatar.Rating` into `AvatarRating` + /// This is only necessary while we maintain both enums. For our next major realease, `Avatar` will use the `AvatarRating` enum + /// rather than defining its own. + var avatarRating: AvatarRating { + switch self.rating { + case .g: .g + case .pg: .pg + case .r: .r + case .x: .x + } + } +} diff --git a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings index a1e77aed..d4982f0d 100644 --- a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings +++ b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings @@ -1,6 +1,21 @@ +/* Rating that indicates that the avatar is suitable for everyone */ +"Avatar.Rating.G.subtitle" = "General"; + +/* Rating that indicates that the avatar may not be suitable for children */ +"Avatar.Rating.PG.subtitle" = "Parental Guidance"; + +/* Rating that indicates that the avatar may not be suitable for children */ +"Avatar.Rating.R.subtitle" = "Restricted"; + +/* Rating that indicates that the avatar is obviously and extremely unsuitable for children */ +"Avatar.Rating.X.subtitle" = "Extreme"; + /* An option in the avatar menu that deletes the avatar */ "AvatarPicker.AvatarAction.delete" = "Delete"; +/* An option in the avatar menu that shows the current rating, and allows the user to change that rating. The rating is used to indicate the appropriateness of an avatar for different audiences, and follows the US system of Motion Picture ratings: G, PG, R, and X. */ +"AvatarPicker.AvatarAction.rate" = "Rating: %@"; + /* An option in the avatar menu that shares the avatar */ "AvatarPicker.AvatarAction.share" = "Share..."; @@ -82,6 +97,12 @@ /* This error message shows when the user attempts to delete an avatar and fails. */ "AvatarPickerViewModel.Delete.Error" = "Oops, there was an error deleting the image."; +/* This error message shows when the user attempts to change the rating of an avatar and fails. */ +"AvatarPickerViewModel.Rating.Error" = "Oops, something didn't quite work out while trying to rate your avatar."; + +/* This confirmation message shows when the user picks a different avatar rating and the change was applied successfully. */ +"AvatarPickerViewModel.RatingUpdate.Success" = "Avatar rating was changed successfully."; + /* This error message shows when the user attempts to share an avatar and fails. */ "AvatarPickerViewModel.Share.Fail" = "Oops, something didn't quite work out while trying to share your avatar."; diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift index d8cac7a4..b41ea362 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift @@ -1,24 +1,21 @@ import Foundation import SwiftUI -enum AvatarAction: String, CaseIterable, Identifiable { +enum AvatarAction: Identifiable { case share case delete + case rating(AvatarRating) case playground - static var allCases: [AvatarAction] { - var cases: [AvatarAction] = [] - if #available(iOS 18.2, *) { - if EnvironmentValues().supportsImagePlayground { - cases.append(.playground) - } + var id: String { + switch self { + case .share: "share" + case .delete: "delete" + case .rating(let rating): rating.rawValue + case .playground: "playground" } - cases.append(contentsOf: [.share, .delete]) - return cases } - var id: String { rawValue } - var icon: Image { switch self { case .delete: @@ -27,6 +24,8 @@ enum AvatarAction: String, CaseIterable, Identifiable { Image(systemName: "square.and.arrow.up") case .playground: Image(systemName: "apple.image.playground") + case .rating: + Image(systemName: "star.leadinghalf.filled") } } @@ -50,6 +49,15 @@ enum AvatarAction: String, CaseIterable, Identifiable { value: "Playground", comment: "An option to show the image playground" ) + case .rating(let rating): + String( + format: SDKLocalizedString( + "AvatarPicker.AvatarAction.rate", + value: "Rating: %@", + comment: "An option in the avatar menu that shows the current rating, and allows the user to change that rating. The rating is used to indicate the appropriateness of an avatar for different audiences, and follows the US system of Motion Picture ratings: G, PG, R, and X." + ), + rating.rawValue + ) } } @@ -57,7 +65,7 @@ enum AvatarAction: String, CaseIterable, Identifiable { switch self { case .delete: .destructive - case .share, .playground: + case .share, .rating, .playground: nil } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift index 33e4c629..93dbaf14 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift @@ -17,6 +17,7 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable { let source: Source let isSelected: Bool let state: State + let rating: AvatarRating var url: URL? { guard case .remote(let url) = source else { @@ -46,14 +47,15 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable { return image } - init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false) { + init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false, rating: AvatarRating = .g) { self.id = id self.source = source self.state = state self.isSelected = isSelected + self.rating = rating } func settingStatus(to newStatus: State) -> AvatarImageModel { - AvatarImageModel(id: id, source: source, state: newStatus, isSelected: isSelected) + AvatarImageModel(id: id, source: source, state: newStatus, isSelected: isSelected, rating: rating) } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index 0b6b4516..05e45db1 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -383,6 +383,10 @@ struct AvatarPickerView: View { playgroundInputItem = PlaygroundInputItem(id: avatar.id, image: Image(uiImage: image)) } } + case .rating(let rating): + Task { + await model.setRating(rating, for: avatar) + } } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 48ac556a..b0d058fa 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -207,7 +207,7 @@ class AvatarPickerViewModel: ObservableObject { let localID = UUID().uuidString - let localImageModel = AvatarImageModel(id: localID, source: .local(image: image), state: .loading) + let localImageModel = AvatarImageModel(id: localID, source: .local(image: image), state: .loading, rating: .g) grid.append(localImageModel) await doUpload(squareImage: image, localID: localID, accessToken: authToken) @@ -290,7 +290,8 @@ class AvatarPickerViewModel: ObservableObject { let newModel = AvatarImageModel( id: imageID, source: .local(image: squareImage), - state: .error(supportsRetry: supportsRetry, errorMessage: errorMessage) + state: .error(supportsRetry: supportsRetry, errorMessage: errorMessage), + rating: grid.model(with: imageID)?.rating ?? .g ) grid.replaceModel(withID: imageID, with: newModel) } @@ -337,6 +338,30 @@ class AvatarPickerViewModel: ObservableObject { await profile } + func setRating(_ rating: AvatarRating, for avatar: AvatarImageModel) async { + guard let authToken else { return } + + do { + let updatedAvatar = try await profileService.setRating( + rating, + for: .hashID(avatar.id), + token: authToken + ) + withAnimation { + grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) + toastManager.showToast(Localized.avatarRatingUpdateSuccess, type: .info) + } + } catch APIError.responseError(let reason) where reason.urlSessionErrorLocalizedDescription != nil { + handleError(message: reason.urlSessionErrorLocalizedDescription ?? Localized.avatarRatingError) + } catch { + handleError(message: Localized.avatarRatingError) + } + + func handleError(message: String) { + toastManager.showToast(message, type: .error) + } + } + func delete(_ avatar: AvatarImageModel) async -> Bool { guard let token = self.authToken else { return false } defer { @@ -419,6 +444,16 @@ extension AvatarPickerViewModel { value: "Oops, something didn't quite work out while trying to share your avatar.", comment: "This error message shows when the user attempts to share an avatar and fails." ) + static let avatarRatingUpdateSuccess = SDKLocalizedString( + "AvatarPickerViewModel.RatingUpdate.Success", + value: "Avatar rating was changed successfully.", + comment: "This confirmation message shows when the user picks a different avatar rating and the change was applied successfully." + ) + static let avatarRatingError = SDKLocalizedString( + "AvatarPickerViewModel.Rating.Error", + value: "Oops, something didn't quite work out while trying to rate your avatar.", + comment: "This error message shows when the user attempts to change the rating of an avatar and fails." + ) } } @@ -440,5 +475,6 @@ extension AvatarImageModel { source = .remote(url: avatar.url(withSize: String(avatarGridItemSize))) state = .loaded isSelected = avatar.isSelected + rating = avatar.avatarRating } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift index 225ec099..3d0c0ef7 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift @@ -80,25 +80,104 @@ struct AvatarPickerAvatarView: View { func actionsMenu() -> some View { Menu { - ForEach(AvatarAction.allCases) { action in - Button(role: action.role) { - onActionTap(action) - } label: { - Label { - Text(action.localizedTitle) - } icon: { - action.icon + Section { + button(for: .share) + if #available(iOS 18.2, *) { + if EnvironmentValues().supportsImagePlayground { + button(for: .playground) } } } + Section { + Menu { + ForEach(AvatarRating.allCases, id: \.self) { rating in + button(for: .rating(rating), isSelected: rating == avatar.rating) + } + } label: { + label(forAction: AvatarAction.rating(avatar.rating)) + } + } + Section { + button(for: .delete) + } } label: { ellipsisView() } } + + private func button( + for action: AvatarAction, + isSelected selected: Bool = false, + systemImageWhenSelected systemImage: String = "checkmark" + ) -> some View { + Button(role: action.role) { + onActionTap(action) + } label: { + switch action { + case .rating(let rating): + let buttonTitle = "\(rating.rawValue) (\(rating.localizedSubtitle))" + + if selected { + label(forAction: action, title: buttonTitle, systemImage: systemImage) + } else { + Text(buttonTitle) + } + case .delete, .playground, .share: + label(forAction: action) + } + } + } + + private func label(forAction action: AvatarAction, title: String? = nil, systemImage: String) -> Label { + label(forAction: action, title: title, image: Image(systemName: systemImage)) + } + + private func label(forAction action: AvatarAction, title: String? = nil, image: Image? = nil) -> Label { + Label { + Text(title ?? action.localizedTitle) + } icon: { + image ?? action.icon + } + } +} + +extension AvatarRating { + fileprivate var localizedSubtitle: String { + switch self { + case .g: + SDKLocalizedString( + "Avatar.Rating.G.subtitle", + value: "General", + comment: "Rating that indicates that the avatar is suitable for everyone" + ) + case .pg: + SDKLocalizedString( + "Avatar.Rating.PG.subtitle", + value: "Parental Guidance", + comment: "Rating that indicates that the avatar may not be suitable for children" + ) + case .r: + SDKLocalizedString( + "Avatar.Rating.R.subtitle", + value: "Restricted", + comment: "Rating that indicates that the avatar may not be suitable for children" + ) + case .x: + SDKLocalizedString( + "Avatar.Rating.X.subtitle", + value: "Extreme", + comment: "Rating that indicates that the avatar is obviously and extremely unsuitable for children" + ) + } + } } #Preview { - let avatar = AvatarImageModel(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")) + let avatar = AvatarImageModel( + id: "1", + source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256"), + rating: .pg + ) return AvatarPickerAvatarView(avatar: avatar, maxLength: AvatarGridConstants.maxAvatarWidth, minLength: AvatarGridConstants.minAvatarWidth) { false } onAvatarTap: { _ in diff --git a/Sources/TestHelpers/Bundle+ResourceBundle.swift b/Sources/TestHelpers/Bundle+ResourceBundle.swift index 2bbdda1a..5bf590b7 100644 --- a/Sources/TestHelpers/Bundle+ResourceBundle.swift +++ b/Sources/TestHelpers/Bundle+ResourceBundle.swift @@ -44,6 +44,10 @@ extension Bundle { testsBundle.jsonData(forResource: "avatarUploadResponse") } + public static var setRatingJsonData: Data { + testsBundle.jsonData(forResource: "avatarSetRatingResponse") + } + public static var getAvatarsJsonData: Data { testsBundle.jsonData(forResource: "avatarsResponse") } diff --git a/Sources/TestHelpers/Resources/avatarSetRatingResponse.json b/Sources/TestHelpers/Resources/avatarSetRatingResponse.json new file mode 100644 index 00000000..6a853791 --- /dev/null +++ b/Sources/TestHelpers/Resources/avatarSetRatingResponse.json @@ -0,0 +1,7 @@ +{ + "image_id": "991a7b71cf9f34...", + "image_url": "https://2.gravatar.com/userimage/1/991a7b71cf934ea2...?size=512", + "rating": "PG", + "updated_date": "2024-09-18T10:17:53Z", + "alt_text": "John Appleseed's avatar" +} diff --git a/Tests/GravatarTests/ProfileServiceTests.swift b/Tests/GravatarTests/ProfileServiceTests.swift index 56b5f6ae..1bf6c60d 100644 --- a/Tests/GravatarTests/ProfileServiceTests.swift +++ b/Tests/GravatarTests/ProfileServiceTests.swift @@ -70,4 +70,31 @@ final class ProfileServiceTests: XCTestCase { XCTFail(error.localizedDescription) } } + + func testSetRatingReturnsAvatar() async throws { + let data = Bundle.setRatingJsonData + let session = URLSessionMock(returnData: data, response: .successResponse()) + let service = ProfileService(urlSession: session) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let referenceAvatar = try decoder.decode(Avatar.self, from: data) + let avatar = try await service.setRating(.g, for: .email("test@example.com"), token: "faketoken") + + XCTAssertEqual(avatar, referenceAvatar) + } + + func testSetRatingHandlesError() async { + let session = URLSessionMock(returnData: Data(), response: .errorResponse(code: 403)) + let service = ProfileService(urlSession: session) + + do { + try await service.setRating(.g, for: .email("test@example.com"), token: "faketoken") + } catch APIError.responseError(reason: .invalidHTTPStatusCode(let response, _)) { + XCTAssertEqual(response.statusCode, 403) + } catch { + XCTFail(error.localizedDescription) + } + } } diff --git a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift index a19eba58..bb113355 100644 --- a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift +++ b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift @@ -1,5 +1,6 @@ import Combine import Foundation +@testable import Gravatar @testable import GravatarUI import TestHelpers import Testing @@ -211,38 +212,136 @@ final class AvatarPickerViewModelTests { #expect(model.grid.avatars.count == 1) } } + + @Test("Handle avatar rating change: Success") + func changeAvatarRatingSucceeds() async throws { + let testAvatarID = "991a7b71cf9f34..." + + await model.refresh() + let avatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID }), "No avatar found") + try #require(avatar.rating == .g) + + await confirmation { confirmation in + model.toastManager.$toasts.sink { toasts in + #expect(toasts.count <= 1) + if toasts.count == 1 { + #expect(toasts.first?.message == AvatarPickerViewModel.Localized.avatarRatingUpdateSuccess) + #expect(toasts.first?.type == .info) + confirmation.confirm() + } + }.store(in: &cancellables) + + await model.setRating(.pg, for: avatar) + } + let resultAvatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID })) + #expect(resultAvatar.rating == .pg) + } + + @Test( + "Handle avatar rating change: Failure", + arguments: [HTTPStatus.unauthorized, .forbidden] + ) + func changeAvatarRatingReturnsError(httpStatus: HTTPStatus) async throws { + let testAvatarID = "991a7b71cf9f34..." + model = Self.createModel(session: .init(returnErrorCode: httpStatus.rawValue)) + + await model.refresh() + let avatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID }), "No avatar found") + try #require(avatar.rating == .g) + + await confirmation { confirmation in + model.toastManager.$toasts.sink { toasts in + #expect(toasts.count <= 1) + if toasts.count == 1 { + #expect(toasts.first?.message == AvatarPickerViewModel.Localized.avatarRatingError) + #expect(toasts.first?.type == .error) + confirmation.confirm() + } + }.store(in: &cancellables) + + await model.setRating(.pg, for: avatar) + } + + let resultAvatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID })) + #expect(resultAvatar.rating == .g, "The rating should not be changed") + } } final class URLSessionAvatarPickerMock: URLSessionProtocol { let returnErrorCode: Int? - enum RequestType: String { - case profiles - case avatars - } - init(returnErrorCode: Int? = nil) { self.returnErrorCode = returnErrorCode } func data(for request: URLRequest) async throws -> (Data, URLResponse) { - if request.httpMethod == "POST" { - if request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true { - return (Bundle.postAvatarSelectedJsonData, HTTPURLResponse.successResponse()) // Avatars data + if request.isSetAvatarForEmailRequest { + return (Bundle.postAvatarSelectedJsonData, HTTPURLResponse.successResponse()) // Avatars data + } + + if request.isSetAvatarRatingRequest { + if let returnErrorCode { + return (Data("".utf8), HTTPURLResponse.errorResponse(code: returnErrorCode)) + } else { + return (Bundle.setRatingJsonData, HTTPURLResponse.successResponse()) // Avatar data } } - if request.url?.absoluteString.contains(RequestType.profiles.rawValue) == true { + + if request.isProfilesRequest { return (Bundle.fullProfileJsonData, HTTPURLResponse.successResponse()) // Profile data - } else if request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true { + } else if request.isAvatarsRequest == true { return (Bundle.getAvatarsJsonData, HTTPURLResponse.successResponse()) // Avatars data } + fatalError("Request not mocked: \(request.url?.absoluteString ?? "unknown request")") } func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) { if let returnErrorCode { - return ("".data(using: .utf8)!, HTTPURLResponse.errorResponse(code: returnErrorCode)) + return (Data("".utf8), HTTPURLResponse.errorResponse(code: returnErrorCode)) } return (Bundle.postAvatarUploadJsonData, HTTPURLResponse.successResponse()) } } + +extension URLRequest { + private enum RequestType: String { + case profiles + case avatars + } + + fileprivate var isAvatarsRequest: Bool { + self.url?.absoluteString.contains(RequestType.avatars.rawValue) == true + } + + fileprivate var isProfilesRequest: Bool { + self.url?.absoluteString.contains(RequestType.profiles.rawValue) == true + } + + fileprivate var isSetAvatarRatingRequest: Bool { + guard self.httpMethod == "PATCH", + self.isAvatarsRequest, + self.httpBody.isDecodable(asType: UpdateAvatarRequest.self) + else { + return false + } + return true + } + + fileprivate var isSetAvatarForEmailRequest: Bool { + guard self.httpMethod == "POST", + self.isAvatarsRequest, + self.httpBody.isDecodable(asType: SetEmailAvatarRequest.self) + else { + return false + } + return true + } +} + +extension Data? { + fileprivate func isDecodable(asType type: T.Type, using decoder: JSONDecoder = JSONDecoder()) -> Bool { + guard let self else { return false } + return (try? decoder.decode(T.self, from: self)) != nil + } +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 992972d0..1eee9014 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -738,7 +738,7 @@ paths: '403': description: Insufficient Scope $ref: '#/components/responses/insufficient_scope' - /me/avatars/{imageHash}: + /me/avatars/{imageId}: delete: summary: Delete avatar description: Deletes a specific avatar for the authenticated user. @@ -748,14 +748,14 @@ paths: security: - oauth: [] parameters: - - name: imageHash + - name: imageId in: path required: true - description: The hash of the avatar to delete. + description: The ID of the avatar to delete. schema: type: string responses: - '200': + '204': description: Avatar deleted successfully '401': description: Not Authorized @@ -763,8 +763,6 @@ paths: '403': description: Insufficient Scope $ref: '#/components/responses/insufficient_scope' - '404': - description: Avatar not found patch: summary: Update avatar data description: Updates the avatar data for a given avatar for the authenticated user. @@ -795,10 +793,10 @@ paths: providing globally unique avatars. required: [] parameters: - - name: imageHash + - name: imageId in: path required: true - description: The hash of the avatar to update. + description: The ID of the avatar to update. schema: type: string responses: @@ -814,8 +812,6 @@ paths: '403': description: Insufficient Scope $ref: '#/components/responses/insufficient_scope' - '404': - description: Avatar not found /me/avatars/{imageId}/email: post: summary: Set avatar for the hashed email @@ -849,7 +845,7 @@ paths: security: - oauth: [] responses: - '200': + '204': description: Avatar successfully set '401': description: Not Authorized