diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 27fb7495e..04491ab9b 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -11,10 +11,16 @@ 030AC0522A64666C00037155 /* UserSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030AC0512A64666C00037155 /* UserSettingsView.swift */; }; 030D4AE62AA1273200A3393D /* ErrorDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030D4AE52AA1273200A3393D /* ErrorDetails.swift */; }; 030D4AE82AA1278400A3393D /* ErrorDetails+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030D4AE72AA1278400A3393D /* ErrorDetails+Mock.swift */; }; + 030E86392AC6B44B000283A6 /* DeletePictrsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E86382AC6B44B000283A6 /* DeletePictrsFile.swift */; }; + 030E863B2AC6C3B1000283A6 /* PictrsRespository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E863A2AC6C3B1000283A6 /* PictrsRespository.swift */; }; + 030E863D2AC6C49E000283A6 /* PictrsRepository+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E863C2AC6C49E000283A6 /* PictrsRepository+Dependency.swift */; }; + 030E863F2AC6C5E9000283A6 /* PictrsImageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E863E2AC6C5E9000283A6 /* PictrsImageModel.swift */; }; + 031A93D62AC847DA0077030C /* UploadConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031A93D52AC847DA0077030C /* UploadConfirmationView.swift */; }; 031BF9532AB24BAF00F4517F /* SiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031BF9522AB24BAF00F4517F /* SiteVersion.swift */; }; 031BF9552AB25AFB00F4517F /* SiteVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031BF9542AB25AFB00F4517F /* SiteVersionTests.swift */; }; 032109472AA7C3FC00912DFC /* CommunityLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032109462AA7C3FC00912DFC /* CommunityLabelView.swift */; }; 032109492AA7C41800912DFC /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032109482AA7C41800912DFC /* AvatarView.swift */; }; + 032DD2FD2AC3594B00F1B33D /* ImageUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032DD2FC2AC3594B00F1B33D /* ImageUploadView.swift */; }; 034C724F2A82B61200B8A4B8 /* LayoutWidgetTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034C724E2A82B61200B8A4B8 /* LayoutWidgetTracker.swift */; }; 035EB0CA2A8687C200227859 /* JumpButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EB0C92A8687C200227859 /* JumpButtonView.swift */; }; 038A16DF2A75172C0087987E /* LayoutWidgetEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038A16DE2A75172C0087987E /* LayoutWidgetEditView.swift */; }; @@ -35,6 +41,8 @@ 03E0B9C82A61F0F400FED265 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */; }; 03E0B9CA2A62B4A400FED265 /* ContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */; }; 03E0B9CC2A62CD5800FED265 /* ThemeSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9CB2A62CD5800FED265 /* ThemeSettingsView.swift */; }; + 03EA79C42AC0D92C00BCDC91 /* PostDetailEditorView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EA79C32AC0D92C00BCDC91 /* PostDetailEditorView+Logic.swift */; }; + 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */; }; 500C168E2A66FAAB006F243B /* HapticManager+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500C168D2A66FAAB006F243B /* HapticManager+Dependency.swift */; }; 5016A2B12A67EB8600B257E8 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5016A2B02A67EB8600B257E8 /* UIViewController.swift */; }; 5016A2B32A67EC0700B257E8 /* NotificationDisplayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5016A2B22A67EC0700B257E8 /* NotificationDisplayer.swift */; }; @@ -463,10 +471,16 @@ 030AC0512A64666C00037155 /* UserSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsView.swift; sourceTree = ""; }; 030D4AE52AA1273200A3393D /* ErrorDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetails.swift; sourceTree = ""; }; 030D4AE72AA1278400A3393D /* ErrorDetails+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ErrorDetails+Mock.swift"; sourceTree = ""; }; + 030E86382AC6B44B000283A6 /* DeletePictrsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletePictrsFile.swift; sourceTree = ""; }; + 030E863A2AC6C3B1000283A6 /* PictrsRespository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictrsRespository.swift; sourceTree = ""; }; + 030E863C2AC6C49E000283A6 /* PictrsRepository+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PictrsRepository+Dependency.swift"; sourceTree = ""; }; + 030E863E2AC6C5E9000283A6 /* PictrsImageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictrsImageModel.swift; sourceTree = ""; }; + 031A93D52AC847DA0077030C /* UploadConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadConfirmationView.swift; sourceTree = ""; }; 031BF9522AB24BAF00F4517F /* SiteVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteVersion.swift; sourceTree = ""; }; 031BF9542AB25AFB00F4517F /* SiteVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteVersionTests.swift; sourceTree = ""; }; 032109462AA7C3FC00912DFC /* CommunityLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLabelView.swift; sourceTree = ""; }; 032109482AA7C41800912DFC /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; + 032DD2FC2AC3594B00F1B33D /* ImageUploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUploadView.swift; sourceTree = ""; }; 034C724E2A82B61200B8A4B8 /* LayoutWidgetTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutWidgetTracker.swift; sourceTree = ""; }; 035EB0C92A8687C200227859 /* JumpButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpButtonView.swift; sourceTree = ""; }; 038A16DE2A75172C0087987E /* LayoutWidgetEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutWidgetEditView.swift; sourceTree = ""; }; @@ -487,6 +501,8 @@ 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsView.swift; sourceTree = ""; }; 03E0B9CB2A62CD5800FED265 /* ThemeSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSettingsView.swift; sourceTree = ""; }; + 03EA79C32AC0D92C00BCDC91 /* PostDetailEditorView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostDetailEditorView+Logic.swift"; sourceTree = ""; }; + 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Pictrs.swift"; sourceTree = ""; }; 500C168D2A66FAAB006F243B /* HapticManager+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HapticManager+Dependency.swift"; sourceTree = ""; }; 5016A2B02A67EB8600B257E8 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; 5016A2B22A67EC0700B257E8 /* NotificationDisplayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDisplayer.swift; sourceTree = ""; }; @@ -1022,6 +1038,15 @@ path = User; sourceTree = ""; }; + 031A93D42AC847D10077030C /* Image Upload */ = { + isa = PBXGroup; + children = ( + 032DD2FC2AC3594B00F1B33D /* ImageUploadView.swift */, + 031A93D52AC847DA0077030C /* UploadConfirmationView.swift */, + ); + path = "Image Upload"; + sourceTree = ""; + }; 031BF9562AB25AFE00F4517F /* Model */ = { isa = PBXGroup; children = ( @@ -1193,6 +1218,7 @@ 50C99B5B2A61F5EB005D57DD /* CommentRepository.swift */, CD82A2522A716B8100111034 /* PersonRepository.swift */, CD6F29A92A78003A00F20B6B /* PostRepository.swift */, + 030E863A2AC6C3B1000283A6 /* PictrsRespository.swift */, 50A881292A72D6BD003E3661 /* CommunityRepository.swift */, 50A881232A71A4CD003E3661 /* PersistenceRepository.swift */, ); @@ -1285,6 +1311,7 @@ 6386E0352A042C59006B3C1D /* Contributor.swift */, 63F0C7A52A05225100A18C5D /* Saved Account.swift */, 63CE4E722A06F5A100405271 /* Access Token.swift */, + 030E863E2AC6C5E9000283A6 /* PictrsImageModel.swift */, 63E5D3932A13CF3600EC1FBD /* Favorite Community.swift */, CD2BD6772A79F55800ECFF89 /* ImageSize.swift */, 030D4AE52AA1273200A3393D /* ErrorDetails.swift */, @@ -1698,6 +1725,7 @@ 637218282A3A2AAD008C4816 /* CreatePostLike.swift */, 637218292A3A2AAD008C4816 /* DeletePost.swift */, 6372182A2A3A2AAD008C4816 /* CreatePost.swift */, + 030E86382AC6B44B000283A6 /* DeletePictrsFile.swift */, 6372182B2A3A2AAD008C4816 /* GetPost.swift */, 6372182C2A3A2AAD008C4816 /* GetPosts.swift */, 6372182D2A3A2AAD008C4816 /* SavePost.swift */, @@ -1883,6 +1911,7 @@ ADF266942A4E8A1E00EBA648 /* PostComposerView.swift */, CD391F952A535F5400E213B5 /* ResponseEditorView.swift */, 03CB329D2A6D8E910021EF27 /* PostDetailEditorView.swift */, + 03EA79C32AC0D92C00BCDC91 /* PostDetailEditorView+Logic.swift */, ); path = Composer; sourceTree = ""; @@ -1914,6 +1943,7 @@ 637218012A3A2AAD008C4816 /* APIClient.swift */, 50A881272A71D66B003E3661 /* APIClient+Community.swift */, 50A8812D2A72D76C003E3661 /* APIClient+Comment.swift */, + 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */, CDEBC3382A9ADE6C00518D9D /* APIClient+Post.swift */, ); path = APIClient; @@ -1980,6 +2010,7 @@ CD69F5702A422EDD0028D4F7 /* InteractionBarView.swift */, 632E8EE427EE63BD007E8D75 /* Components */, CDF1EF152A6C3BC2003594B6 /* End Of Feed View.swift */, + 031A93D42AC847D10077030C /* Image Upload */, CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */, CDC1C9422A7AC24600072E3D /* ReadCheck.swift */, CD309C452A93FBD300988F95 /* Logo View.swift */, @@ -2052,6 +2083,7 @@ 50A881252A71A511003E3661 /* PersistenceRepository+Dependency.swift */, CD82A2562A716D7C00111034 /* PersonRepository+Dependency.swift */, CD6F29AB2A78015200F20B6B /* PostRepository+Dependency.swift */, + 030E863C2AC6C49E000283A6 /* PictrsRepository+Dependency.swift */, ); path = Repositories; sourceTree = ""; @@ -2573,6 +2605,7 @@ CDB0117F2A6F70A000D043EB /* Editor Tracker.swift in Sources */, 6354F30A2A2E20040074C08D /* Alert - Multiple Alerts.swift in Sources */, 6318EDC727EE4E1500BFCAE8 /* Post.swift in Sources */, + 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */, 6372186C2A3A2AAD008C4816 /* SaveComment.swift in Sources */, E4D4DBA22A7F233200C4F3DE /* FancyTabNavigationSelectionHashValueEnvironmentKey.swift in Sources */, 6DD8677A2A5083A200BEB00F /* Community Sidebar Link.swift in Sources */, @@ -2588,6 +2621,7 @@ 5064D03D2A6DE0AA00B22EE3 /* Notifier.swift in Sources */, CDC65D912A86B830007205E5 /* DeleteAccountView.swift in Sources */, CD391F9C2A53980900E213B5 /* ReplyToCommentReply.swift in Sources */, + 030E863D2AC6C49E000283A6 /* PictrsRepository+Dependency.swift in Sources */, 630737892A1CD1E900039852 /* String.swift in Sources */, 50811B402A9205EE006BA3F2 /* CommunityResponse+Mock.swift in Sources */, 03E0B9CC2A62CD5800FED265 /* ThemeSettingsView.swift in Sources */, @@ -2624,6 +2658,7 @@ 637218672A3A2AAD008C4816 /* GetPersonDetails.swift in Sources */, B1A26FE12A44AAB200B91A32 /* Navigation getter.swift in Sources */, 6332FDC027EFB05F0009A98A /* Settings Item.swift in Sources */, + 031A93D62AC847DA0077030C /* UploadConfirmationView.swift in Sources */, CD8C55342A95515C0060B75B /* Onboarding Text.swift in Sources */, 50C99B602A6299D8005D57DD /* ErrorHandler.swift in Sources */, 50F830F82A4C92BF00D67099 /* FeedTrackerItemProviding.swift in Sources */, @@ -2654,11 +2689,13 @@ E4F0B56F2ABD00A000BC3E4A /* PresentationBackgroundInteraction.swift in Sources */, CDF842612A49EA3900723DA0 /* Mentions Tracker.swift in Sources */, 6D693A4C2A51B99E009E2D76 /* APICommentReport.swift in Sources */, + 030E863B2AC6C3B1000283A6 /* PictrsRespository.swift in Sources */, 63344C672A08D4E3001BC616 /* AppearanceSettingsView.swift in Sources */, E49F0E762A90395400BC4EE3 /* NavigationPath+Helpers.swift in Sources */, B1955A1F2A606F010056CF99 /* EasterFlagsTracker.swift in Sources */, 63D24ED92A169A5F005CCA81 /* UIApplication.swift in Sources */, 039439912A98FA6100463032 /* UserFeedView.swift in Sources */, + 03EA79C42AC0D92C00BCDC91 /* PostDetailEditorView+Logic.swift in Sources */, 637218482A3A2AAD008C4816 /* APICommentReply.swift in Sources */, 032109472AA7C3FC00912DFC /* CommunityLabelView.swift in Sources */, 637218502A3A2AAD008C4816 /* APIPersonAggregates.swift in Sources */, @@ -2741,6 +2778,7 @@ 03CB329E2A6D8E910021EF27 /* PostDetailEditorView.swift in Sources */, CD69F5752A42479A0028D4F7 /* Comment Item Logic.swift in Sources */, 6D7782342A48EE8C008AC1BF /* APIPrivateMessageView.swift in Sources */, + 030E86392AC6B44B000283A6 /* DeletePictrsFile.swift in Sources */, 50811B342A9204EB006BA3F2 /* APICommunityAggregates+Mock.swift in Sources */, B11A1A782A4EFF2B00520DB4 /* Feed Root.swift in Sources */, CDC1C93F2A7AB8C700072E3D /* AccessibilitySettingsView.swift in Sources */, @@ -2806,6 +2844,7 @@ 5016A2B12A67EB8600B257E8 /* UIViewController.swift in Sources */, 6372184C2A3A2AAD008C4816 /* APIPostView.swift in Sources */, CDB0117D2A6F703800D043EB /* CommentEditor.swift in Sources */, + 030E863F2AC6C5E9000283A6 /* PictrsImageModel.swift in Sources */, 632E8EE627EE63D3007E8D75 /* UpvoteButtonView.swift in Sources */, B1A26FE32A45B11800B91A32 /* View - Handle Lemmy Links.swift in Sources */, 03B643572A6864CD00F65700 /* TabBarSettingsView.swift in Sources */, @@ -2830,6 +2869,7 @@ 03A1B3F72A84000400AB0DE0 /* APIContentAggregatesProtocol.swift in Sources */, CD4DBC032A6F803C001A1E61 /* ReplyToPost.swift in Sources */, CD6483302A38D31C00EE6CA3 /* UpvoteCounterView.swift in Sources */, + 032DD2FD2AC3594B00F1B33D /* ImageUploadView.swift in Sources */, 88B165B82A8643F4007C9115 /* View - NavigationBar Color.swift in Sources */, 030AC0522A64666C00037155 /* UserSettingsView.swift in Sources */, CDA2C5262A705D6000649D5A /* PostEditor.swift in Sources */, diff --git a/Mlem/API/APIClient/APIClient+Pictrs.swift b/Mlem/API/APIClient/APIClient+Pictrs.swift new file mode 100644 index 000000000..cd62ff9e9 --- /dev/null +++ b/Mlem/API/APIClient/APIClient+Pictrs.swift @@ -0,0 +1,107 @@ +// +// APIClient+Pictrs.swift +// Mlem +// +// Created by Sjmarf on 24/09/2023. +// + +import Foundation + +extension APIClient { + @discardableResult + func deleteImage(file: PictrsFile) async throws -> ImageDeleteResponse { + let request = try ImageDeleteRequest(session: session, file: file.file, deleteToken: file.deleteToken) + return try await perform(request: request) + } + + func uploadImage( + _ imageData: Data, + onProgress progressCallback: @escaping (_ progress: Double) -> Void, + onCompletion completionCallback: @escaping(_ response: ImageUploadResponse?) -> Void, + `catch`: @escaping (Error) -> Void + ) async throws -> Task<(), any Error> { + + let delegate = ImageUploadDelegate(callback: progressCallback) + // Modify the instance URL to remove "api/v3" and add "pictrs/image". + var components = URLComponents() + components.scheme = try self.session.instanceUrl.scheme + components.host = try self.session.instanceUrl.host + components.path = "/pictrs/image" + + guard let url = components.url else { + throw APIClientError.response(.init(error: "Failed to modify instance URL to add pictrs."), nil) + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let boundary = UUID().uuidString + + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("jwt=\(try session.token)", forHTTPHeaderField: "Cookie") + + let multiPartForm: MultiPartForm = try .init( + mimeType: "image/png", + fileName: "image.png", + imageData: imageData, + auth: session.token + ) + + return Task { [request] in + do { + let (data, _) = try await self.urlSession.upload( + for: request, + from: multiPartForm.createField(boundary: boundary), + delegate: delegate) + do { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let response = try decoder.decode(ImageUploadResponse.self, from: data) + completionCallback(response) + } catch DecodingError.dataCorrupted { + throw APIClientError.decoding(data) + } + } catch { + if !Task.isCancelled { + `catch`(error) + } + } + } + } +} + +private struct MultiPartForm: Codable { + var mimeType: String + var fileName: String + var imageData: Data + var auth: String + + func createField(boundary: String) -> Data { + var data = Data() + data.append(Data("--\(boundary)\r\n".utf8)) + data.append(Data("Content-Disposition: form-data; name=\"images[]\"; filename=\"\(fileName)\"\r\n".utf8)) + data.append(Data("Content-Type: \(mimeType)\r\n".utf8)) + data.append(Data("\r\n".utf8)) + data.append(imageData) + data.append(Data("\r\n".utf8)) + data.append(Data("--\(boundary)--\r\n".utf8)) + return data + } +} + +private class ImageUploadDelegate: NSObject, URLSessionTaskDelegate { + public let callback: (Double) -> Void + + public init(callback: @escaping (Double) -> Void) { + self.callback = callback + } + + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + callback(Double(totalBytesSent) / Double(totalBytesExpectedToSend)) + } +} diff --git a/Mlem/API/Requests/Post/DeletePictrsFile.swift b/Mlem/API/Requests/Post/DeletePictrsFile.swift new file mode 100644 index 000000000..fbd486fe4 --- /dev/null +++ b/Mlem/API/Requests/Post/DeletePictrsFile.swift @@ -0,0 +1,36 @@ +// +// DeletePictrsFile.swift +// Mlem +// +// Created by Sjmarf on 29/09/2023. +// + +import Foundation + +struct ImageDeleteRequest: APIRequest { + var path: String + var instanceURL: URL + + var endpoint: URL { + instanceURL + .appending(path: path) + } + + typealias Response = ImageDeleteResponse + + init(session: APISession, file: String, deleteToken: String) throws { + var components = URLComponents() + components.scheme = try session.instanceUrl.scheme + components.host = try session.instanceUrl.host + components.path = "/pictrs/image" + + guard let url = components.url else { + throw APIClientError.response(.init(error: "Failed to modify instance URL to delete from pictrs."), nil) + } + self.instanceURL = url + + self.path = "/delete/\(deleteToken)/\(file)" + } +} + +struct ImageDeleteResponse: Decodable { } diff --git a/Mlem/Dependency/Repositories/PictrsRepository+Dependency.swift b/Mlem/Dependency/Repositories/PictrsRepository+Dependency.swift new file mode 100644 index 000000000..942c02471 --- /dev/null +++ b/Mlem/Dependency/Repositories/PictrsRepository+Dependency.swift @@ -0,0 +1,20 @@ +// +// PictrsRepository+Dependency.swift +// Mlem +// +// Created by Sjmarf on 29/09/2023. +// + +import Dependencies +import Foundation + +extension PictrsRespository: DependencyKey { + static let liveValue = PictrsRespository() +} + +extension DependencyValues { + var pictrsRepository: PictrsRespository { + get { self[PictrsRespository.self] } + set { self[PictrsRespository.self] = newValue } + } +} diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index 8d442da3f..95b7e1675 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -128,6 +128,7 @@ struct Icons { static let updated: String = "clock.arrow.2.circlepath" static let favorite: String = "star" static let favoriteFill: String = "star.fill" + static let close: String = "multiply" // common operations static let share: String = "square.and.arrow.up" @@ -136,7 +137,8 @@ struct Icons { static let filter: String = "line.3.horizontal.decrease.circle" static let filterFill: String = "line.3.horizontal.decrease.circle.fill" static let menu: String = "ellipsis" - static let importSymbol: String = "square.and.arrow.down" // Just "import" can't be used :( + static let `import`: String = "square.and.arrow.down" + static let attachment: String = "paperclip" static let edit: String = "pencil" static let delete: String = "trash" diff --git a/Mlem/Models/PictrsImageModel.swift b/Mlem/Models/PictrsImageModel.swift new file mode 100644 index 000000000..ea8500aa8 --- /dev/null +++ b/Mlem/Models/PictrsImageModel.swift @@ -0,0 +1,30 @@ +// +// PictrsImageModel.swift +// Mlem +// +// Created by Sjmarf on 29/09/2023. +// + +import SwiftUI + +struct ImageUploadResponse: Codable { + public let msg: String + public let files: [PictrsFile]? +} + +struct PictrsFile: Codable, Equatable { + public let file: String + public let deleteToken: String +} + +struct PictrsImageModel { + enum UploadState { + case waiting + case readyToUpload(data: Data) + case uploading(progress: Double) + case uploaded(file: PictrsFile?) + case failed(String?) + } + var image: Image? + var state: UploadState = .waiting +} diff --git a/Mlem/Repositories/PictrsRespository.swift b/Mlem/Repositories/PictrsRespository.swift new file mode 100644 index 000000000..eb18c84be --- /dev/null +++ b/Mlem/Repositories/PictrsRespository.swift @@ -0,0 +1,72 @@ +// +// PictrsRespository.swift +// Mlem +// +// Created by Sjmarf on 29/09/2023. +// + +import Dependencies +import PhotosUI +import SwiftUI + +class PictrsRespository { + @Dependency(\.apiClient) var apiClient + + func uploadImage( + imageModel: PictrsImageModel, + onUpdate updateCallback: @escaping (_ imageModel: PictrsImageModel) -> Void + ) async throws -> Task<(), any Error>? { + var imageModel = imageModel + guard case .readyToUpload(data: let data) = imageModel.state else { + imageModel.state = .failed("No data") + updateCallback(imageModel) + return nil + } + do { + return try await apiClient.uploadImage(data, onProgress: { + print("Uploading: \(round($0*100))%") + imageModel.state = .uploading(progress: $0) + updateCallback(imageModel) + }, onCompletion: { response in + if let response = response { + if let firstFile = response.files?.first { + imageModel.state = .uploaded(file: firstFile) + updateCallback(imageModel) + } else { + print("Upload failed: \(response.msg)") + imageModel.state = .failed(response.msg) + updateCallback(imageModel) + } + } else { + print("Upload failed: Response is nil") + imageModel.state = .failed(nil) + updateCallback(imageModel) + } + }, catch: { error in + print("Upload failed: \(error)") + switch error { + case APIClientError.decoding(let data): + imageModel.state = .failed(String(data: data, encoding: .utf8)) + default: + imageModel.state = .failed(error.localizedDescription) + } + + updateCallback(imageModel) + }) + } catch { + print("Upload failed: \(error)") + imageModel.state = .failed(error.localizedDescription) + updateCallback(imageModel) + } + return nil + } + + func deleteImage(file: PictrsFile) async throws { + // A decoding error will always be throws because the delete request has no response... there's + // certainly a better way to handle this by making ImageDeleteRequest itself have no response + // associated with it, possibly via an intermediate APIRequestWithResponse protocol + do { + try await apiClient.deleteImage(file: file) + } catch APIClientError.decoding(_) { } + } +} diff --git a/Mlem/Views/Shared/Components/Image Upload/ImageUploadView.swift b/Mlem/Views/Shared/Components/Image Upload/ImageUploadView.swift new file mode 100644 index 000000000..99a700b3c --- /dev/null +++ b/Mlem/Views/Shared/Components/Image Upload/ImageUploadView.swift @@ -0,0 +1,84 @@ +// +// ImageUploadView.swift +// Mlem +// +// Created by Sjmarf on 26/09/2023. +// + +import SwiftUI +import PhotosUI +import Dependencies + +struct ImageUploadView: View { + var imageModel: PictrsImageModel + let onCancel: () -> Void + + var body: some View { + VStack { + HStack(spacing: AppConstants.postAndCommentSpacing) { + if let image = imageModel.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: AppConstants.thumbnailSize, height: AppConstants.thumbnailSize, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: AppConstants.smallItemCornerRadius)) + } else { + placeHolderImage + } + VStack(alignment: .leading) { + Text("Attached Image") + Spacer() + HStack { + switch imageModel.state { + case .uploading(let progress): + if progress == 1 { + Text("Processing...") + ProgressView() + .controlSize(.small) + .padding(.horizontal, 6) + } else { + Text("Uploading...") + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle()) + .frame(width: 100, height: 10) + } + case .uploaded: + Text("Uploaded") + case .failed(let msg): + Text(msg ?? "Failed") + .foregroundStyle(.red) + default: + Text("Waiting...") + } + } + .foregroundStyle(.secondary) + } + .frame(height: AppConstants.thumbnailSize - 20) + Spacer() + } + .padding(10) + } + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius) + .fill(Color(UIColor.secondarySystemBackground)) + } + .overlay(alignment: .topTrailing) { + Button(action: onCancel, label: { + Image(systemName: Icons.close) + .fontWeight(.semibold) + .tint(.secondary) + .padding(5) + .background(Circle().fill(.background)) + }) + .padding(5) + } + } + + @ViewBuilder + var placeHolderImage: some View { + RoundedRectangle(cornerRadius: AppConstants.smallItemCornerRadius) + .fill(.secondary) + .frame(width: AppConstants.thumbnailSize, height: AppConstants.thumbnailSize) + } +} diff --git a/Mlem/Views/Shared/Components/Image Upload/UploadConfirmationView.swift b/Mlem/Views/Shared/Components/Image Upload/UploadConfirmationView.swift new file mode 100644 index 000000000..04ffa4970 --- /dev/null +++ b/Mlem/Views/Shared/Components/Image Upload/UploadConfirmationView.swift @@ -0,0 +1,92 @@ +// +// UploadConfirmationView.swift +// Mlem +// +// Created by Sjmarf on 30/09/2023. +// + +import SwiftUI +import PhotosUI +import Dependencies + +struct UploadConfirmationView: View { + @Dependency(\.apiClient) var apiClient + @AppStorage("confirmImageUploads") var confirmImageUploads: Bool = false + + @Binding var isPresented: Bool + let onUpload: () -> Void + let onCancel: () -> Void + let imageModel: PictrsImageModel? + + var instanceName: String { + do { + return try apiClient.session.instanceUrl.host() ?? "your instance" + } catch { + return "your instance" + } + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + if let image = imageModel?.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape( + RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius) + ) + } + Spacer() + .frame(height: 100) + } + .scrollIndicators(.hidden) + .overlay(alignment: .bottom) { + LinearGradient( + colors: [Color.systemBackground, Color.clear], + startPoint: .bottom, + endPoint: .top + ) + .frame(height: 100) + } + switch imageModel?.state { + case .readyToUpload: + VStack(spacing: 0) { + VStack(spacing: 16) { + Text("Upload this image to \(instanceName)?") + .font(.largeTitle) + .multilineTextAlignment(.center) + Toggle("Ask to confirm every time", isOn: $confirmImageUploads) + .controlSize(.mini) + .padding(.horizontal) + Button { + onUpload() + isPresented = false + } label: { + Text("Upload") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + Button { + onCancel() + isPresented = false + } label: { + Text("Cancel") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + .buttonStyle(.bordered) + } + .padding(.top, 15) + .padding(.bottom, 20) + .background(Color.systemBackground) + } + + default: + Text("Something went wrong.") + } + } + .padding() + } +} diff --git a/Mlem/Views/Shared/Composer/PostComposerView.swift b/Mlem/Views/Shared/Composer/PostComposerView.swift index 1ae1f647f..363f70a04 100644 --- a/Mlem/Views/Shared/Composer/PostComposerView.swift +++ b/Mlem/Views/Shared/Composer/PostComposerView.swift @@ -22,10 +22,6 @@ struct PostComposerView: View { @State var postBody: String @State var isNSFW: Bool - private var hasPostContent: Bool { - !postTitle.isEmpty || !postURL.isEmpty || !postBody.isEmpty - } - init(editModel: PostEditorModel) { self.postTracker = editModel.postTracker self.editModel = editModel @@ -71,7 +67,6 @@ struct PostComposerView: View { dismiss() } - .interactiveDismissDisabled(hasPostContent) } } diff --git a/Mlem/Views/Shared/Composer/PostDetailEditorView+Logic.swift b/Mlem/Views/Shared/Composer/PostDetailEditorView+Logic.swift new file mode 100644 index 000000000..e4af147ce --- /dev/null +++ b/Mlem/Views/Shared/Composer/PostDetailEditorView+Logic.swift @@ -0,0 +1,145 @@ +// +// PostEditorDetailView+Logic.swift +// Mlem +// +// Created by Sjmarf on 24/09/2023. +// + +import Foundation +import SwiftUI +import PhotosUI + +extension PostDetailEditorView { + var hasPostContent: Bool { + !postTitle.isEmpty || !postURL.isEmpty || !postBody.isEmpty || imageModel != nil + } + + var isReadyToPost: Bool { + switch imageModel?.state { + case nil, .uploaded: + return postTitle.trimmed.isNotEmpty + default: + return false + } + } + + var isValidURL: Bool { + guard postURL.lowercased().hasPrefix("http://") || + postURL.lowercased().hasPrefix("https://") else { + return false // URL protocol is missing + } + + guard URL(string: postURL) != nil else { + return false // Not Parsable + } + + return true + } + + func submitPost() async { + do { + guard postTitle.trimmed.isNotEmpty else { + errorDialogMessage = "You need to enter a title for your post." + isShowingErrorDialog = true + return + } + + guard postURL.lowercased().isEmpty || isValidURL else { + errorDialogMessage = "You seem to have entered an invalid URL, please check it again." + isShowingErrorDialog = true + return + } + + isSubmitting = true + + try await onSubmit() + + } catch { + isSubmitting = false + errorHandler.handle(error) + } + } + + func loadImage() { + guard let selection = imageSelection else { return } + self.imageModel = .init() + Task { + do { + let data = try await selection.loadTransferable(type: Data.self) + DispatchQueue.main.async { + if let data = data { + self.imageModel?.state = .readyToUpload(data: data) + if let uiImage = UIImage(data: data) { + imageModel?.image = Image(uiImage: uiImage) + } + if askedForPermissionToUploadImages == false || confirmImageUploads { + showingUploadConfirmation = true + } else { + uploadImage() + } + } else { + self.imageModel?.state = .failed("Invalid format") + } + } + } catch { + DispatchQueue.main.async { + self.imageModel?.state = .failed(String(describing: error)) + } + } + } + } + + func uploadImage() { + guard let imageModel = imageModel else { return } + Task(priority: .userInitiated) { + self.uploadTask = try await pictrsRepository.uploadImage( + imageModel: imageModel, + onUpdate: { newValue in + self.imageModel = newValue + switch newValue.state { + case .uploaded(let file): + if let file = file { + do { + var components = URLComponents() + components.scheme = try apiClient.session.instanceUrl.scheme + components.host = try apiClient.session.instanceUrl.host + components.path = "/pictrs/image/\(file.file)" + postURL = components.url?.absoluteString ?? "" + } catch { + self.imageModel?.state = .failed(nil) + } + } else { + + } + default: + postURL = "" + } + } + ) + } + } + + func cancelUpload() { + if let task = self.uploadTask { + task.cancel() + } + switch imageModel?.state { + case .uploaded(file: let file): + if let file = file { + Task { + do { + try await pictrsRepository.deleteImage(file: file) + } catch { + errorHandler.handle(error) + } + print("Deleted from pictrs") + } + } + default: + break + } + imageSelection = nil + imageModel = nil + postURL = "" + } +} diff --git a/Mlem/Views/Shared/Composer/PostDetailEditorView.swift b/Mlem/Views/Shared/Composer/PostDetailEditorView.swift index ae6de5ac8..54461e712 100644 --- a/Mlem/Views/Shared/Composer/PostDetailEditorView.swift +++ b/Mlem/Views/Shared/Composer/PostDetailEditorView.swift @@ -7,6 +7,7 @@ import Dependencies import SwiftUI +import PhotosUI extension HorizontalAlignment { enum LabelStart: AlignmentID { @@ -23,8 +24,13 @@ struct PostDetailEditorView: View { case title, url, body } + @Dependency(\.apiClient) var apiClient + @Dependency(\.pictrsRepository) var pictrsRepository @Dependency(\.errorHandler) var errorHandler + @AppStorage("promptUser.permission.privacy.allowImageUploads") var askedForPermissionToUploadImages: Bool = false + @AppStorage("confirmImageUploads") var confirmImageUploads: Bool = false + @Environment(\.dismiss) var dismiss var community: APICommunity @@ -39,6 +45,12 @@ struct PostDetailEditorView: View { @State var isShowingErrorDialog: Bool = false @State var errorDialogMessage: String = "" + @State var showingUploadConfirmation: Bool = false + @State var showingPhotosPicker: Bool = false + @State var imageSelection: PhotosPickerItem? + @State var imageModel: PictrsImageModel? + @State var uploadTask: Task<(), any Error>? + @FocusState private var focusedField: Field? init( @@ -57,52 +69,6 @@ struct PostDetailEditorView: View { self.onSubmit = onSubmit } - private var isReadyToPost: Bool { - // This only requirement to post is a title - postTitle.trimmed.isNotEmpty - } - - private var isValidURL: Bool { - guard postURL.lowercased().hasPrefix("http://") || - postURL.lowercased().hasPrefix("https://") else { - return false // URL protocol is missing - } - - guard URL(string: postURL) != nil else { - return false // Not Parsable - } - - return true - } - - func submitPost() async { - do { - guard postTitle.trimmed.isNotEmpty else { - errorDialogMessage = "You need to enter a title for your post." - isShowingErrorDialog = true - return - } - - guard postURL.lowercased().isEmpty || isValidURL else { - errorDialogMessage = "You seem to have entered an invalid URL, please check it again." - isShowingErrorDialog = true - return - } - - isSubmitting = true - - try await onSubmit() - - } catch { - isSubmitting = false - errorHandler.handle(error) - } - } - - func uploadImage() { - print("Uploading") - } - var body: some View { ZStack { VStack(spacing: 15) { @@ -143,30 +109,37 @@ struct PostDetailEditorView: View { focusedField = .title } } + } - // URL Row - HStack { - Text("URL") - .foregroundColor(.secondary) - .dynamicTypeSize(.small ... .accessibility2) - .accessibilityHidden(true) - - TextField("Your post link (Optional)", text: $postURL) - .alignmentGuide(.labelStart) { $0[HorizontalAlignment.leading] } - .dynamicTypeSize(.small ... .accessibility2) - .keyboardType(.URL) - .autocorrectionDisabled() - .autocapitalization(.none) - .accessibilityLabel("URL") - .focused($focusedField, equals: .url) - - // Upload button, temporarily hidden - // Button(action: uploadImage) { - // Image(systemName: "paperclip") - // .font(.title3) - // .dynamicTypeSize(.medium) - // } - // .accessibilityLabel("Upload Image") + // URL Row + if let imageModel = imageModel { + ImageUploadView(imageModel: imageModel, onCancel: cancelUpload) + } else { + VStack(alignment: .labelStart) { + HStack { + Text("URL") + .foregroundColor(.secondary) + .dynamicTypeSize(.small ... .accessibility2) + .accessibilityHidden(true) + + TextField("Your post link (Optional)", text: $postURL) + .alignmentGuide(.labelStart) { $0[HorizontalAlignment.leading] } + .dynamicTypeSize(.small ... .accessibility2) + .keyboardType(.URL) + .autocorrectionDisabled() + .autocapitalization(.none) + .accessibilityLabel("URL") + .focused($focusedField, equals: .url) + + Button { + showingPhotosPicker = true + } label: { + Image(systemName: Icons.attachment) + .font(.title3) + .dynamicTypeSize(.medium) + } + .accessibilityLabel("Upload Image") + } } } @@ -201,6 +174,7 @@ struct PostDetailEditorView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel", role: .destructive) { + cancelUpload() dismiss() } .tint(.red) @@ -217,6 +191,7 @@ struct PostDetailEditorView: View { }.disabled(isSubmitting || !isReadyToPost) } } + .interactiveDismissDisabled(hasPostContent) .alert("Submit Failed", isPresented: $isShowingErrorDialog) { Button("OK", role: .cancel) {} } message: { @@ -224,5 +199,21 @@ struct PostDetailEditorView: View { } .navigationBarColor() .navigationBarTitleDisplayMode(.inline) + .photosPicker(isPresented: $showingPhotosPicker, selection: $imageSelection, matching: .images) + .onChange(of: imageSelection) { _ in + loadImage() + } + .sheet(isPresented: $showingUploadConfirmation) { + UploadConfirmationView( + isPresented: $showingUploadConfirmation, + onUpload: uploadImage, + onCancel: cancelUpload, + imageModel: imageModel + ) + .interactiveDismissDisabled() + .onAppear { + askedForPermissionToUploadImages = true + } + } } } diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift index 3061b1f47..ac5973ca9 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift @@ -59,7 +59,7 @@ struct FiltersSettingsView: View { Text("Import Filters") } icon: { if showSettingsIcons { - Image(systemName: Icons.importSymbol) + Image(systemName: Icons.import) } } } diff --git a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift index 64d257a3d..46cc08435 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift @@ -11,8 +11,8 @@ import SwiftUI struct GeneralSettingsView: View { @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker + @AppStorage("confirmImageUploads") var confirmImageUploads: Bool = true @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot @@ -61,6 +61,16 @@ struct GeneralSettingsView: View { // swiftlint:enable line_length } + Section { + SwitchableSettingsItem( + settingPictureSystemName: Icons.attachment, + settingName: "Confirm Image Uploads", + isTicked: $confirmImageUploads + ) + } footer: { + Text("Ask to confirm your choice before uploading an image to your instance.") + } + Section { SelectableSettingsItem( settingIconSystemName: defaultFeed.settingsIconName,