From e52302a0ce234dd9b2e410b88b46abe43dc4668b Mon Sep 17 00:00:00 2001 From: Marcin Czachursk Date: Sun, 15 Jan 2023 12:41:55 +0100 Subject: [PATCH] Implement error handling. --- .../MastodonKit/Errors/NetworkError.swift | 2 +- .../Extensions/URLResponse+StatusCode.swift | 14 +++++ .../MastodonKit/MastodonClient+Account.swift | 53 ++++-------------- .../MastodonKit/MastodonClient+Accounts.swift | 4 +- .../MastodonKit/MastodonClient+Context.swift | 7 +-- .../MastodonClient+Convenience.swift | 8 +-- .../MastodonClient+Instances.swift | 6 +-- .../MastodonClient+StatusActions.swift | 36 ++++--------- .../Sources/MastodonKit/MastodonClient.swift | 39 ++++++++------ Vernissage.xcodeproj/project.pbxproj | 25 +++++++++ Vernissage/CoreData/AccountDataHandler.swift | 4 +- .../CoreData/ApplicationSettingsHandler.swift | 2 +- Vernissage/CoreData/StatusDataHandler.swift | 22 ++++++-- Vernissage/Extensions/Color+Assets.swift | 7 +++ Vernissage/Haptics/HapticService.swift | 2 +- Vernissage/Models/TintColor.swift | 4 ++ .../Services/AuthorizationService.swift | 7 ++- Vernissage/Services/CacheAvatarService.swift | 2 +- Vernissage/Services/ErrorsService.swift | 20 +++++++ Vernissage/Services/StatusService.swift | 9 ++++ Vernissage/Services/TimelineService.swift | 11 +--- Vernissage/Services/ToastrService.swift | 54 +++++++++++++++++++ Vernissage/Views/ComposeView.swift | 3 +- Vernissage/Views/FollowersView.swift | 2 +- Vernissage/Views/FollowingView.swift | 2 +- Vernissage/Views/HomeFeedView.swift | 4 +- Vernissage/Views/StatusView.swift | 15 ++++-- Vernissage/Views/UserProfileView.swift | 2 +- Vernissage/Widgets/InteractionRow.swift | 14 +++-- .../Widgets/StatusView/CommentsSection.swift | 2 +- .../UserProfile/UserProfileHeader.swift | 2 +- .../UserProfile/UserProfileStatuses.swift | 4 +- 32 files changed, 245 insertions(+), 143 deletions(-) create mode 100644 MastodonKit/Sources/MastodonKit/Extensions/URLResponse+StatusCode.swift create mode 100644 Vernissage/Services/ErrorsService.swift create mode 100644 Vernissage/Services/ToastrService.swift diff --git a/MastodonKit/Sources/MastodonKit/Errors/NetworkError.swift b/MastodonKit/Sources/MastodonKit/Errors/NetworkError.swift index caa7cd1..21911f9 100644 --- a/MastodonKit/Sources/MastodonKit/Errors/NetworkError.swift +++ b/MastodonKit/Sources/MastodonKit/Errors/NetworkError.swift @@ -14,7 +14,7 @@ extension NetworkError: LocalizedError { public var errorDescription: String? { switch self { case .notSuccessResponse(let response): - let statusCode = (response as? HTTPURLResponse)?.status + let statusCode = response.statusCode() return NSLocalizedString("Network request returned not success status code: '\(statusCode?.localizedDescription ?? "unknown")'.", comment: "It's error returned from remote server.") } } diff --git a/MastodonKit/Sources/MastodonKit/Extensions/URLResponse+StatusCode.swift b/MastodonKit/Sources/MastodonKit/Extensions/URLResponse+StatusCode.swift new file mode 100644 index 0000000..51c720d --- /dev/null +++ b/MastodonKit/Sources/MastodonKit/Extensions/URLResponse+StatusCode.swift @@ -0,0 +1,14 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation + +public extension URLResponse { + func statusCode() -> HTTPStatusCode? { + let statusCode = (self as? HTTPURLResponse)?.status + return statusCode + } +} diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient+Account.swift b/MastodonKit/Sources/MastodonKit/MastodonClient+Account.swift index 77203ed..d7980d7 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient+Account.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient+Account.swift @@ -14,12 +14,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - return try JSONDecoder().decode(Account.self, from: data) + return try await downloadJson(Account.self, request: request) } func getRelationship(for accountId: String) async throws -> Relationship? { @@ -28,13 +23,8 @@ public extension MastodonClientAuthenticated { target: Mastodon.Account.relationships([accountId]), withBearerToken: token ) - - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - let relationships = try JSONDecoder().decode([Relationship].self, from: data) + + let relationships = try await downloadJson([Relationship].self, request: request) return relationships.first } @@ -51,12 +41,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - return try JSONDecoder().decode([Status].self, from: data) + return try await downloadJson([Status].self, request: request) } func follow(for accountId: String) async throws -> Relationship { @@ -66,12 +51,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - return try JSONDecoder().decode(Relationship.self, from: data) + return try await downloadJson(Relationship.self, request: request) } func unfollow(for accountId: String) async throws -> Relationship { @@ -81,12 +61,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - return try JSONDecoder().decode(Relationship.self, from: data) + return try await downloadJson(Relationship.self, request: request) } func getFollowers(for accountId: String, page: Int = 1) async throws -> [Account] { @@ -96,12 +71,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - return try JSONDecoder().decode([Account].self, from: data) + return try await downloadJson([Account].self, request: request) } func getFollowing(for accountId: String, page: Int = 1) async throws -> [Account] { @@ -110,12 +80,7 @@ public extension MastodonClientAuthenticated { target: Mastodon.Account.following(accountId, nil, nil, nil, nil, page), withBearerToken: token ) - - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - return try JSONDecoder().decode([Account].self, from: data) + + return try await downloadJson([Account].self, request: request) } } diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient+Accounts.swift b/MastodonKit/Sources/MastodonKit/MastodonClient+Accounts.swift index dfcf199..ff47293 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient+Accounts.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient+Accounts.swift @@ -8,8 +8,6 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Account.self, from: data) + return try await downloadJson(Account.self, request: request) } } diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient+Context.swift b/MastodonKit/Sources/MastodonKit/MastodonClient+Context.swift index b06c5ae..e371887 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient+Context.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient+Context.swift @@ -14,11 +14,6 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, response) = try await urlSession.data(for: request) - guard (response as? HTTPURLResponse)?.status?.responseType == .success else { - throw NetworkError.notSuccessResponse(response) - } - - return try JSONDecoder().decode(Context.self, from: data) + return try await downloadJson(Context.self, request: request) } } diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient+Convenience.swift b/MastodonKit/Sources/MastodonKit/MastodonClient+Convenience.swift index e8fdea2..675d8d8 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient+Convenience.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient+Convenience.swift @@ -17,9 +17,7 @@ public extension MastodonClient { ) ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(App.self, from: data) + return try await downloadJson(App.self, request: request) } func authenticate(app: App, scope: Scopes) async throws -> OAuthSwiftCredential { // todo: we should not load OAuthSwift objects here @@ -65,9 +63,7 @@ public extension MastodonClient { target: Mastodon.OAuth.authenticate(app, username, password, scope.asScopeString) ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(AccessToken.self, from: data) + return try await downloadJson(AccessToken.self, request: request) } } diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient+Instances.swift b/MastodonKit/Sources/MastodonKit/MastodonClient+Instances.swift index 7d063c7..953ea90 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient+Instances.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient+Instances.swift @@ -2,9 +2,7 @@ import Foundation public extension MastodonClient { func readInstanceInformation() async throws -> Instance { - let request = try Self.request(for: baseURL, target: Mastodon.Instances.instance ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Instance.self, from: data) + let request = try Self.request(for: baseURL, target: Mastodon.Instances.instance) + return try await downloadJson(Instance.self, request: request) } } diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient+StatusActions.swift b/MastodonKit/Sources/MastodonKit/MastodonClient+StatusActions.swift index 00a6770..52f4896 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient+StatusActions.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient+StatusActions.swift @@ -6,10 +6,8 @@ public extension MastodonClientAuthenticated { for: baseURL, target: Mastodon.Statuses.status(statusId), withBearerToken: token) - - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + + return try await downloadJson(Status.self, request: request) } func boost(statusId: StatusId) async throws -> Status { @@ -19,10 +17,8 @@ public extension MastodonClientAuthenticated { target: Mastodon.Statuses.reblog(statusId), withBearerToken: token ) - - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + + return try await downloadJson(Status.self, request: request) } func unboost(statusId: StatusId) async throws -> Status { @@ -32,9 +28,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + return try await downloadJson(Status.self, request: request) } func bookmark(statusId: StatusId) async throws -> Status { @@ -44,9 +38,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + return try await downloadJson(Status.self, request: request) } func unbookmark(statusId: StatusId) async throws -> Status { @@ -56,9 +48,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + return try await downloadJson(Status.self, request: request) } func favourite(statusId: StatusId) async throws -> Status { @@ -68,9 +58,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + return try await downloadJson(Status.self, request: request) } func unfavourite(statusId: StatusId) async throws -> Status { @@ -80,9 +68,7 @@ public extension MastodonClientAuthenticated { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + return try await downloadJson(Status.self, request: request) } func new(statusComponents: Mastodon.Statuses.Components) async throws -> Status { @@ -91,8 +77,6 @@ public extension MastodonClientAuthenticated { target: Mastodon.Statuses.new(statusComponents), withBearerToken: token) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Status.self, from: data) + return try await downloadJson(Status.self, request: request) } } diff --git a/MastodonKit/Sources/MastodonKit/MastodonClient.swift b/MastodonKit/Sources/MastodonKit/MastodonClient.swift index 2cde9c4..fe50490 100644 --- a/MastodonKit/Sources/MastodonKit/MastodonClient.swift +++ b/MastodonKit/Sources/MastodonKit/MastodonClient.swift @@ -61,6 +61,15 @@ public class MastodonClient: MastodonClientProtocol { oAuthContinuation?.resume(throwing: MastodonClientError.oAuthCancelled) oAuthHandle?.cancel() } + + public func downloadJson(_ type: T.Type, request: URLRequest) async throws -> T where T: Decodable { + let (data, response) = try await urlSession.data(for: request) + guard (response as? HTTPURLResponse)?.status?.responseType == .success else { + throw NetworkError.notSuccessResponse(response) + } + + return try JSONDecoder().decode(type, from: data) + } } public class MastodonClientAuthenticated: MastodonClientProtocol { @@ -86,10 +95,8 @@ public class MastodonClientAuthenticated: MastodonClientProtocol { target: Mastodon.Timelines.home(maxId, sinceId, minId, limit), withBearerToken: token ) - - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode([Status].self, from: data) + + return try await downloadJson([Status].self, request: request) } public func getPublicTimeline(isLocal: Bool = false, @@ -102,9 +109,8 @@ public class MastodonClientAuthenticated: MastodonClientProtocol { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - return try JSONDecoder().decode([Status].self, from: data) + return try await downloadJson([Status].self, request: request) } public func getTagTimeline(tag: String, @@ -118,9 +124,7 @@ public class MastodonClientAuthenticated: MastodonClientProtocol { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode([Status].self, from: data) + return try await downloadJson([Status].self, request: request) } public func saveMarkers(_ markers: [Mastodon.Markers.Timeline: StatusId]) async throws -> Markers { @@ -130,9 +134,7 @@ public class MastodonClientAuthenticated: MastodonClientProtocol { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Markers.self, from: data) + return try await downloadJson(Markers.self, request: request) } public func readMarkers(_ markers: Set) async throws -> Markers { @@ -142,8 +144,15 @@ public class MastodonClientAuthenticated: MastodonClientProtocol { withBearerToken: token ) - let (data, _) = try await urlSession.data(for: request) - - return try JSONDecoder().decode(Markers.self, from: data) + return try await downloadJson(Markers.self, request: request) + } + + public func downloadJson(_ type: T.Type, request: URLRequest) async throws -> T where T: Decodable { + let (data, response) = try await urlSession.data(for: request) + guard (response as? HTTPURLResponse)?.status?.responseType == .success else { + throw NetworkError.notSuccessResponse(response) + } + + return try JSONDecoder().decode(type, from: data) } } diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 75bd39a..98c6d31 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ F85DBF8F296732E20069BF89 /* FollowersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85DBF8E296732E20069BF89 /* FollowersView.swift */; }; F85DBF912967385F0069BF89 /* FollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85DBF902967385F0069BF89 /* FollowingView.swift */; }; F85DBF93296760790069BF89 /* CacheAvatarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85DBF92296760790069BF89 /* CacheAvatarService.swift */; }; + F85E1320297409CD006A051D /* ErrorsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85E131F297409CD006A051D /* ErrorsService.swift */; }; F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69E296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift */; }; F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69F296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift */; }; F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A229604161002E8F88 /* AccountDataHandler.swift */; }; @@ -92,6 +93,8 @@ F89D6C4C297197FE001DA3D4 /* ImageViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89D6C4B297197FE001DA3D4 /* ImageViewerViewModel.swift */; }; F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7D2965FD89001D8331 /* UserProfileView.swift */; }; F8A93D802965FED4001D8331 /* AccountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A93D7F2965FED4001D8331 /* AccountService.swift */; }; + F8B1E64F2973F61400EE0D10 /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = F8B1E64E2973F61400EE0D10 /* Drops */; }; + F8B1E6512973FB7E00EE0D10 /* ToastrService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8B1E6502973FB7E00EE0D10 /* ToastrService.swift */; }; F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14391296AF0B3001FE31D /* String+Exif.swift */; }; F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C14393296AF21B001FE31D /* Double+Round.swift */; }; F8CC95CE2970761D00C9C2AC /* TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CC95CD2970761D00C9C2AC /* TintColor.swift */; }; @@ -127,6 +130,7 @@ F85DBF8E296732E20069BF89 /* FollowersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersView.swift; sourceTree = ""; }; F85DBF902967385F0069BF89 /* FollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingView.swift; sourceTree = ""; }; F85DBF92296760790069BF89 /* CacheAvatarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheAvatarService.swift; sourceTree = ""; }; + F85E131F297409CD006A051D /* ErrorsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorsService.swift; sourceTree = ""; }; F866F69E296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationSettings+CoreDataClass.swift"; sourceTree = ""; }; F866F69F296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationSettings+CoreDataProperties.swift"; sourceTree = ""; }; F866F6A229604161002E8F88 /* AccountDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataHandler.swift; sourceTree = ""; }; @@ -185,6 +189,7 @@ F8A93D7D2965FD89001D8331 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; F8A93D7F2965FED4001D8331 /* AccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountService.swift; sourceTree = ""; }; F8AF2A61297073FE00D2DA3F /* Vernissage20230112-001.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Vernissage20230112-001.xcdatamodel"; sourceTree = ""; }; + F8B1E6502973FB7E00EE0D10 /* ToastrService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastrService.swift; sourceTree = ""; }; F8C14391296AF0B3001FE31D /* String+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Exif.swift"; sourceTree = ""; }; F8C14393296AF21B001FE31D /* Double+Round.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Round.swift"; sourceTree = ""; }; F8CC95CD2970761D00C9C2AC /* TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TintColor.swift; sourceTree = ""; }; @@ -199,6 +204,7 @@ F8210DD52966BB7E001D9973 /* Nuke in Frameworks */, F8210DD72966BB7E001D9973 /* NukeExtensions in Frameworks */, F8210DD92966BB7E001D9973 /* NukeUI in Frameworks */, + F8B1E64F2973F61400EE0D10 /* Drops in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -392,6 +398,8 @@ F8A93D7F2965FED4001D8331 /* AccountService.swift */, F8210DE02966D0C4001D9973 /* StatusService.swift */, F85DBF92296760790069BF89 /* CacheAvatarService.swift */, + F8B1E6502973FB7E00EE0D10 /* ToastrService.swift */, + F85E131F297409CD006A051D /* ErrorsService.swift */, ); path = Services; sourceTree = ""; @@ -472,6 +480,7 @@ F8210DD62966BB7E001D9973 /* NukeExtensions */, F8210DD82966BB7E001D9973 /* NukeUI */, F89992C6296D3DF8005994BF /* MastodonKit */, + F8B1E64E2973F61400EE0D10 /* Drops */, ); productName = Vernissage; productReference = F88C2468295C37B80006098B /* Vernissage.app */; @@ -503,6 +512,7 @@ mainGroup = F88C245F295C37B80006098B; packageReferences = ( F8210DD32966BB7E001D9973 /* XCRemoteSwiftPackageReference "Nuke" */, + F8B1E64D2973F61400EE0D10 /* XCRemoteSwiftPackageReference "Drops" */, ); productRefGroup = F88C2469295C37B80006098B /* Products */; projectDirPath = ""; @@ -565,6 +575,7 @@ F80048042961850500E6868A /* AttachmentData+CoreDataProperties.swift in Sources */, F86B7223296C4BF500EE59EC /* ContentWarning.swift in Sources */, F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */, + F8B1E6512973FB7E00EE0D10 /* ToastrService.swift in Sources */, F85DBF912967385F0069BF89 /* FollowingView.swift in Sources */, F89992CE296D92E7005994BF /* AttachmentViewModel.swift in Sources */, F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */, @@ -593,6 +604,7 @@ F85D497D29640D5900751DF7 /* InteractionRow.swift in Sources */, F866F6A729604629002E8F88 /* SignInView.swift in Sources */, F8C14392296AF0B3001FE31D /* String+Exif.swift in Sources */, + F85E1320297409CD006A051D /* ErrorsService.swift in Sources */, F88C246C295C37B80006098B /* VernissageApp.swift in Sources */, F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */, F88FAD25295F3FF7009B20C9 /* FederatedFeedView.swift in Sources */, @@ -832,6 +844,14 @@ minimumVersion = 11.5.3; }; }; + F8B1E64D2973F61400EE0D10 /* XCRemoteSwiftPackageReference "Drops" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/omaralbeik/Drops"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.6.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -854,6 +874,11 @@ isa = XCSwiftPackageProductDependency; productName = MastodonKit; }; + F8B1E64E2973F61400EE0D10 /* Drops */ = { + isa = XCSwiftPackageProductDependency; + package = F8B1E64D2973F61400EE0D10 /* XCRemoteSwiftPackageReference "Drops" */; + productName = Drops; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Vernissage/CoreData/AccountDataHandler.swift b/Vernissage/CoreData/AccountDataHandler.swift index 8f2cf11..6670573 100644 --- a/Vernissage/CoreData/AccountDataHandler.swift +++ b/Vernissage/CoreData/AccountDataHandler.swift @@ -18,7 +18,7 @@ class AccountDataHandler { do { return try context.fetch(fetchRequest) } catch { - print("Error during fetching accounts") + ErrorService.shared.handle(error, message: "Accounts cannot be retrieved (getAccountsData).") return [] } } @@ -48,7 +48,7 @@ class AccountDataHandler { do { return try context.fetch(fetchRequest).first } catch { - print("Error during fetching status (getAccountData)") + ErrorService.shared.handle(error, message: "Error during fetching status (getAccountData).") return nil } } diff --git a/Vernissage/CoreData/ApplicationSettingsHandler.swift b/Vernissage/CoreData/ApplicationSettingsHandler.swift index d9ac916..c6c203a 100644 --- a/Vernissage/CoreData/ApplicationSettingsHandler.swift +++ b/Vernissage/CoreData/ApplicationSettingsHandler.swift @@ -19,7 +19,7 @@ class ApplicationSettingsHandler { do { settingsList = try context.fetch(fetchRequest) } catch { - print("Error during fetching application settings") + ErrorService.shared.handle(error, message: "Error during fetching application settings.") } if let settings = settingsList.first { diff --git a/Vernissage/CoreData/StatusDataHandler.swift b/Vernissage/CoreData/StatusDataHandler.swift index d25a9f6..dc8641f 100644 --- a/Vernissage/CoreData/StatusDataHandler.swift +++ b/Vernissage/CoreData/StatusDataHandler.swift @@ -26,7 +26,7 @@ class StatusDataHandler { do { return try context.fetch(fetchRequest).first } catch { - print("Error during fetching status (getStatusData)") + ErrorService.shared.handle(error, message: "Error during fetching status (getStatusData).") return nil } } @@ -45,7 +45,7 @@ class StatusDataHandler { let statuses = try context.fetch(fetchRequest) return statuses.first } catch { - print("Error during fetching maximum status (getMaximumStatus)") + ErrorService.shared.handle(error, message: "Error during fetching maximum status (getMaximumStatus).") return nil } } @@ -64,11 +64,27 @@ class StatusDataHandler { let statuses = try context.fetch(fetchRequest) return statuses.first } catch { - print("Error during fetching minimum status (getMinimumtatus)") + ErrorService.shared.handle(error, message: "Error during fetching minimum status (getMinimumtatus).") return nil } } + func remove(accountId: String, statusId: String) { + let status = self.getStatusData(accountId: accountId, statusId: statusId) + guard let status else { + return + } + + let context = CoreDataHandler.shared.container.viewContext + context.delete(status) + + do { + try context.save() + } catch { + ErrorService.shared.handle(error, message: "Error during deleting status (remove).") + } + } + func createStatusDataEntity(viewContext: NSManagedObjectContext? = nil) -> StatusData { let context = viewContext ?? CoreDataHandler.shared.container.viewContext return StatusData(context: context) diff --git a/Vernissage/Extensions/Color+Assets.swift b/Vernissage/Extensions/Color+Assets.swift index b8076c9..7ca07ee 100644 --- a/Vernissage/Extensions/Color+Assets.swift +++ b/Vernissage/Extensions/Color+Assets.swift @@ -24,3 +24,10 @@ extension Color { static let accentColor9 = Color("AccentColor9") static let accentColor10 = Color("AccentColor10") } + + +extension Color { + func toUIColor() -> UIColor { + UIColor(self) + } +} diff --git a/Vernissage/Haptics/HapticService.swift b/Vernissage/Haptics/HapticService.swift index 48ee58e..bf53c0d 100644 --- a/Vernissage/Haptics/HapticService.swift +++ b/Vernissage/Haptics/HapticService.swift @@ -32,7 +32,7 @@ public final class HapticService: ObservableObject { try player?.start(atTime: CHHapticTimeImmediate) } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Haptic service failed.") } } diff --git a/Vernissage/Models/TintColor.swift b/Vernissage/Models/TintColor.swift index 2381ba9..4a8070f 100644 --- a/Vernissage/Models/TintColor.swift +++ b/Vernissage/Models/TintColor.swift @@ -34,4 +34,8 @@ public enum TintColor: Int { return Color.accentColor10 } } + + public func uiColor() -> UIColor { + return self.color().toUIColor() + } } diff --git a/Vernissage/Services/AuthorizationService.swift b/Vernissage/Services/AuthorizationService.swift index b951521..a4ecb07 100644 --- a/Vernissage/Services/AuthorizationService.swift +++ b/Vernissage/Services/AuthorizationService.swift @@ -32,8 +32,7 @@ public class AuthorizationService { try await self.refreshCredentials(accountData: accountData) result(accountData) } catch { - // TODO: show information to the user. - print("Cannot refresh credentials!!!") + ErrorService.shared.handle(error, message: "Issues during refreshing credentials.", showToastr: true) } } } @@ -94,7 +93,7 @@ public class AuthorizationService { accountData.avatarData = avatarData } catch { - print("Avatar has not been downloaded") + ErrorService.shared.handle(error, message: "Avatar has not been downloaded.") } } @@ -151,7 +150,7 @@ public class AuthorizationService { accountData.avatarData = avatarData } catch { - print("Avatar has not been downloaded") + ErrorService.shared.handle(error, message: "Avatar has not been downloaded.") } } diff --git a/Vernissage/Services/CacheAvatarService.swift b/Vernissage/Services/CacheAvatarService.swift index e875de6..eb5507d 100644 --- a/Vernissage/Services/CacheAvatarService.swift +++ b/Vernissage/Services/CacheAvatarService.swift @@ -35,7 +35,7 @@ public class CacheAvatarService { CacheAvatarService.shared.addImage(for: accountId, data: avatarData) } } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Downloading avatar into cache failed.") } } diff --git a/Vernissage/Services/ErrorsService.swift b/Vernissage/Services/ErrorsService.swift new file mode 100644 index 0000000..f9cc386 --- /dev/null +++ b/Vernissage/Services/ErrorsService.swift @@ -0,0 +1,20 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation + +public class ErrorService { + public static let shared = ErrorService() + private init() { } + + public func handle(_ error: Error, message: String, showToastr: Bool = false) { + if showToastr { + ToastrService.shared.showError(subtitle: message) + } + + print("Error: \(error.localizedDescription)") + } +} diff --git a/Vernissage/Services/StatusService.swift b/Vernissage/Services/StatusService.swift index 2b408bc..11dc83f 100644 --- a/Vernissage/Services/StatusService.swift +++ b/Vernissage/Services/StatusService.swift @@ -11,6 +11,15 @@ public class StatusService { public static let shared = StatusService() private init() { } + public func getStatus(withId statusId: String, and accountData: AccountData?) async throws -> Status? { + guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else { + return nil + } + + let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken) + return try await client.read(statusId: statusId) + } + func favourite(statusId: String, accountData: AccountData?) async throws -> Status? { guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else { return nil diff --git a/Vernissage/Services/TimelineService.swift b/Vernissage/Services/TimelineService.swift index 8832719..e0dee95 100644 --- a/Vernissage/Services/TimelineService.swift +++ b/Vernissage/Services/TimelineService.swift @@ -40,15 +40,6 @@ public class TimelineService { return try await self.loadData(for: accountData, on: backgroundContext, minId: newestStatus?.id) } - public func getStatus(withId statusId: String, and accountData: AccountData?) async throws -> Status? { - guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else { - return nil - } - - let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken) - return try await client.read(statusId: statusId) - } - public func getComments(for statusId: String, and accountData: AccountData) async throws -> [CommentViewModel] { var commentViewModels: [CommentViewModel] = [] @@ -196,7 +187,7 @@ public class TimelineService { return (attachmentUrl.key, nil) } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Fatching all images failed.") return (attachmentUrl.key, nil) } } diff --git a/Vernissage/Services/ToastrService.swift b/Vernissage/Services/ToastrService.swift new file mode 100644 index 0000000..3c6d6e6 --- /dev/null +++ b/Vernissage/Services/ToastrService.swift @@ -0,0 +1,54 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation +import SwiftUI +import Drops + +public class ToastrService { + public static let shared = ToastrService() + private init() { } + + public func showSuccess(_ title: String, imageSystemName: String, subtitle: String? = nil) { + let drop = Drop( + title: title, + subtitle: subtitle, + icon: self.createImage(systemName: imageSystemName, color: ApplicationState.shared.tintColor.uiColor()), + action: .init { + Drops.hideCurrent() + }, + position: .top, + duration: 2.0, + accessibility: "" + ) + + Drops.show(drop) + } + + public func showError(title: String = "Unexpected error", imageSystemName: String = "ant.circle.fill", subtitle: String? = nil) { + let drop = Drop( + title: "Unexpected error", + subtitle: subtitle, + icon: self.createImage(systemName: imageSystemName, color: Color.red.toUIColor()), + action: .init { + Drops.hideCurrent() + }, + position: .top, + duration: 2.0, + accessibility: "" + ) + + Drops.show(drop) + } + + private func createImage(systemName: String, color: UIColor) -> UIImage? { + guard let uiImage = UIImage(systemName: systemName) else { + return nil + } + + return uiImage.withTintColor(color, renderingMode: .alwaysOriginal) + } +} diff --git a/Vernissage/Views/ComposeView.swift b/Vernissage/Views/ComposeView.swift index afc76f9..9d18dde 100644 --- a/Vernissage/Views/ComposeView.swift +++ b/Vernissage/Views/ComposeView.swift @@ -89,6 +89,7 @@ struct ComposeView: View { Task { await self.publishStatus() dismiss() + ToastrService.shared.showSuccess("Status published", imageSystemName: "message.fill") } } label: { Text("Publish") @@ -115,7 +116,7 @@ struct ComposeView: View { status: Mastodon.Statuses.Components(inReplyToId: self.statusViewModel?.id, text: self.text), accountData: self.applicationState.accountData) } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Error during post status.", showToastr: true) } } diff --git a/Vernissage/Views/FollowersView.swift b/Vernissage/Views/FollowersView.swift index 1e14c02..0beb490 100644 --- a/Vernissage/Views/FollowersView.swift +++ b/Vernissage/Views/FollowersView.swift @@ -81,7 +81,7 @@ struct FollowersView: View { await self.downloadAvatars(accounts: accountsFromApi) self.accounts.append(contentsOf: accountsFromApi) } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Error during download followers from server.", showToastr: true) } } diff --git a/Vernissage/Views/FollowingView.swift b/Vernissage/Views/FollowingView.swift index abc5182..94b94ee 100644 --- a/Vernissage/Views/FollowingView.swift +++ b/Vernissage/Views/FollowingView.swift @@ -81,7 +81,7 @@ struct FollowingView: View { await self.downloadAvatars(accounts: accountsFromApi) self.accounts.append(contentsOf: accountsFromApi) } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Error during download following from server.", showToastr: true) } } diff --git a/Vernissage/Views/HomeFeedView.swift b/Vernissage/Views/HomeFeedView.swift index 3bbf467..3e92cfc 100644 --- a/Vernissage/Views/HomeFeedView.swift +++ b/Vernissage/Views/HomeFeedView.swift @@ -49,7 +49,7 @@ struct HomeFeedView: View { } } } catch { - print("Error", error) + ErrorService.shared.handle(error, message: "Error during download statuses from server.", showToastr: true) } } } @@ -95,7 +95,7 @@ struct HomeFeedView: View { _ = try await TimelineService.shared.onTopOfList(for: accountData) } } catch { - print("Error", error) + ErrorService.shared.handle(error, message: "Error during download statuses from server.", showToastr: true) } } } diff --git a/Vernissage/Views/StatusView.swift b/Vernissage/Views/StatusView.swift index 9dacf57..e2ea057 100644 --- a/Vernissage/Views/StatusView.swift +++ b/Vernissage/Views/StatusView.swift @@ -10,6 +10,8 @@ import AVFoundation struct StatusView: View { @EnvironmentObject var applicationState: ApplicationState + @Environment(\.dismiss) private var dismiss + @State var statusId: String @State var imageBlurhash: String? @State var imageWidth: Int32? @@ -116,7 +118,7 @@ struct StatusView: View { } // Get status from API. - if let status = try await TimelineService.shared.getStatus(withId: self.statusId, and: self.applicationState.accountData) { + if let status = try await StatusService.shared.getStatus(withId: self.statusId, and: self.applicationState.accountData) { let statusViewModel = StatusViewModel(status: status) // Download images and recalculate exif data. @@ -137,8 +139,15 @@ struct StatusView: View { _ = try await TimelineService.shared.updateStatus(statusDataFromDatabase, accountData: accountData, basedOn: status) } } - } catch { - print("Error \(error.localizedDescription)") + } catch NetworkError.notSuccessResponse(let response) { + if response.statusCode() == HTTPStatusCode.notFound, let accountId = self.applicationState.accountData?.id { + StatusDataHandler.shared.remove(accountId: accountId, statusId: self.statusId) + ErrorService.shared.handle(NetworkError.notSuccessResponse(response), message: "Status not existing anymore.", showToastr: true) + dismiss() + } + } + catch { + ErrorService.shared.handle(error, message: "Error during download status from server.", showToastr: true) } } } diff --git a/Vernissage/Views/UserProfileView.swift b/Vernissage/Views/UserProfileView.swift index 73207d0..e3633c2 100644 --- a/Vernissage/Views/UserProfileView.swift +++ b/Vernissage/Views/UserProfileView.swift @@ -35,7 +35,7 @@ struct UserProfileView: View { do { try await self.loadData() } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Error during download account from server.", showToastr: true) } } } diff --git a/Vernissage/Widgets/InteractionRow.swift b/Vernissage/Widgets/InteractionRow.swift index e5d6720..c2f9002 100644 --- a/Vernissage/Widgets/InteractionRow.swift +++ b/Vernissage/Widgets/InteractionRow.swift @@ -6,6 +6,7 @@ import SwiftUI import MastodonKit +import Drops struct InteractionRow: View { @EnvironmentObject var applicationState: ApplicationState @@ -48,8 +49,10 @@ struct InteractionRow: View { self.reblogged = status.reblogged } + + ToastrService.shared.showSuccess("Reblogged", imageSystemName: "paperplane.fill") } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Reblog action failed.", showToastr: true) } } label: { HStack(alignment: .center) { @@ -74,8 +77,10 @@ struct InteractionRow: View { self.favourited = status.favourited } + + ToastrService.shared.showSuccess("Favourited", imageSystemName: "hand.thumbsup.fill") } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Favourite action failed.", showToastr: true) } } label: { HStack(alignment: .center) { @@ -95,8 +100,10 @@ struct InteractionRow: View { self.bookmarked.toggle() } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Favourite action failed.", showToastr: true) } + + ToastrService.shared.showSuccess("Bookmarked", imageSystemName: "bookmark.fill") } label: { Image(systemName: self.bookmarked ? "bookmark.fill" : "bookmark") } @@ -105,6 +112,7 @@ struct InteractionRow: View { ActionButton { // TODO: Share. + ToastrService.shared.showError(subtitle: "Sending new status failed!") } label: { Image(systemName: "square.and.arrow.up") } diff --git a/Vernissage/Widgets/StatusView/CommentsSection.swift b/Vernissage/Widgets/StatusView/CommentsSection.swift index 5e8666c..cb79f3b 100644 --- a/Vernissage/Widgets/StatusView/CommentsSection.swift +++ b/Vernissage/Widgets/StatusView/CommentsSection.swift @@ -59,7 +59,7 @@ struct CommentsSection: View { self.commentViewModels = try await TimelineService.shared.getComments(for: statusId, and: accountData) } } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Comments cannot be downloaded.", showToastr: true) } } } diff --git a/Vernissage/Widgets/UserProfile/UserProfileHeader.swift b/Vernissage/Widgets/UserProfile/UserProfileHeader.swift index 537585a..9a08727 100644 --- a/Vernissage/Widgets/UserProfile/UserProfileHeader.swift +++ b/Vernissage/Widgets/UserProfile/UserProfileHeader.swift @@ -115,7 +115,7 @@ struct UserProfileHeader: View { } } } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Relationship action failed.", showToastr: true) } } } diff --git a/Vernissage/Widgets/UserProfile/UserProfileStatuses.swift b/Vernissage/Widgets/UserProfile/UserProfileStatuses.swift index 829d06a..3272c38 100644 --- a/Vernissage/Widgets/UserProfile/UserProfileStatuses.swift +++ b/Vernissage/Widgets/UserProfile/UserProfileStatuses.swift @@ -39,7 +39,7 @@ struct UserProfileStatuses: View { do { try await self.loadMoreStatuses() } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Loading more statuses failed.", showToastr: true) } } .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) @@ -53,7 +53,7 @@ struct UserProfileStatuses: View { do { try await self.loadStatuses() } catch { - print("Error \(error.localizedDescription)") + ErrorService.shared.handle(error, message: "Loading statuses failed.", showToastr: true) } } }