From a04d941b775bc7456cd9e803d69a050b2145e210 Mon Sep 17 00:00:00 2001 From: Sjmarf <78750526+Sjmarf@users.noreply.github.com> Date: Sat, 30 Sep 2023 16:23:32 +0100 Subject: [PATCH] Update --- Mlem.xcodeproj/project.pbxproj | 14 +++- Mlem/API/APIClient/APIClient+Pictrs.swift | 4 +- Mlem/Icons.swift | 3 +- Mlem/Models/PictrsImageModel.swift | 4 +- Mlem/Repositories/PictrsRespository.swift | 70 ++++++++--------- .../{ => Image Upload}/ImageUploadView.swift | 2 + .../Image Upload/UploadConfirmationView.swift | 77 +++++++++++++++++++ .../Composer/PostDetailEditorView+Logic.swift | 37 ++++++++- .../Composer/PostDetailEditorView.swift | 27 ++++--- .../Views/Filters/FiltersSettingsView.swift | 2 +- .../Views/General/GeneralSettingsView.swift | 12 ++- 11 files changed, 193 insertions(+), 59 deletions(-) rename Mlem/Views/Shared/Components/{ => Image Upload}/ImageUploadView.swift (97%) create mode 100644 Mlem/Views/Shared/Components/Image Upload/UploadConfirmationView.swift diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 159191857..576ea7e1a 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 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 */; }; @@ -471,6 +472,7 @@ 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 = ""; }; @@ -1030,6 +1032,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 = ( @@ -1991,7 +2002,7 @@ CD69F5702A422EDD0028D4F7 /* InteractionBarView.swift */, 632E8EE427EE63BD007E8D75 /* Components */, CDF1EF152A6C3BC2003594B6 /* End Of Feed View.swift */, - 032DD2FC2AC3594B00F1B33D /* ImageUploadView.swift */, + 031A93D42AC847D10077030C /* Image Upload */, CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */, CDC1C9422A7AC24600072E3D /* ReadCheck.swift */, CD309C452A93FBD300988F95 /* Logo View.swift */, @@ -2629,6 +2640,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 */, diff --git a/Mlem/API/APIClient/APIClient+Pictrs.swift b/Mlem/API/APIClient/APIClient+Pictrs.swift index 33cbff63f..cd62ff9e9 100644 --- a/Mlem/API/APIClient/APIClient+Pictrs.swift +++ b/Mlem/API/APIClient/APIClient+Pictrs.swift @@ -61,7 +61,9 @@ extension APIClient { throw APIClientError.decoding(data) } } catch { - `catch`(error) + if !Task.isCancelled { + `catch`(error) + } } } } diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index 09d719ca4..9a9f09a87 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -136,7 +136,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" // settings static let upvoteOnSave: String = "arrow.up.heart" diff --git a/Mlem/Models/PictrsImageModel.swift b/Mlem/Models/PictrsImageModel.swift index 5c90ebb29..ea8500aa8 100644 --- a/Mlem/Models/PictrsImageModel.swift +++ b/Mlem/Models/PictrsImageModel.swift @@ -19,10 +19,12 @@ struct PictrsFile: Codable, Equatable { 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 = .uploading(progress: 0) + var state: UploadState = .waiting } diff --git a/Mlem/Repositories/PictrsRespository.swift b/Mlem/Repositories/PictrsRespository.swift index 3e59122c1..eb18c84be 100644 --- a/Mlem/Repositories/PictrsRespository.swift +++ b/Mlem/Repositories/PictrsRespository.swift @@ -14,54 +14,48 @@ class PictrsRespository { func uploadImage( imageModel: PictrsImageModel, - imageSelection: PhotosPickerItem, 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 { - let data = try await imageSelection.loadTransferable(type: Data.self) - - if let data = data { - if let uiImage = UIImage(data: data) { - imageModel.image = Image(uiImage: uiImage) - } - 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) - } + 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 is nil") - imageModel.state = .failed(nil) + print("Upload failed: \(response.msg)") + imageModel.state = .failed(response.msg) 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(String(describing: error)) - } - + } else { + print("Upload failed: Response is nil") + imageModel.state = .failed(nil) updateCallback(imageModel) - }) - } else { - imageModel.state = .failed("No data to upload") + } + }, 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(String(describing: error)) + imageModel.state = .failed(error.localizedDescription) updateCallback(imageModel) } return nil @@ -70,7 +64,7 @@ class PictrsRespository { 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 - // object, possibly via an intermediate APIRequestWithResponse protocol + // 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/ImageUploadView.swift b/Mlem/Views/Shared/Components/Image Upload/ImageUploadView.swift similarity index 97% rename from Mlem/Views/Shared/Components/ImageUploadView.swift rename to Mlem/Views/Shared/Components/Image Upload/ImageUploadView.swift index 2dc7a3a09..99a700b3c 100644 --- a/Mlem/Views/Shared/Components/ImageUploadView.swift +++ b/Mlem/Views/Shared/Components/Image Upload/ImageUploadView.swift @@ -47,6 +47,8 @@ struct ImageUploadView: View { case .failed(let msg): Text(msg ?? "Failed") .foregroundStyle(.red) + default: + Text("Waiting...") } } .foregroundStyle(.secondary) 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..b44cb73fa --- /dev/null +++ b/Mlem/Views/Shared/Components/Image Upload/UploadConfirmationView.swift @@ -0,0 +1,77 @@ +// +// 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 { + switch imageModel?.state { + case .readyToUpload: + VStack(spacing: 16) { + Spacer() + if let image = imageModel?.image { + image + .resizable() + .aspectRatio(1, contentMode: .fit) + .clipShape( + RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius) + ) + .padding(.top) + } + Spacer() + Text("Upload this image to \(instanceName)?") + .font(.largeTitle) + .multilineTextAlignment(.center) + Spacer() + Toggle("Ask to confirm every time", isOn: $confirmImageUploads) + .controlSize(.mini) + .padding(.horizontal) + Spacer() + 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(.horizontal) + + default: + Text("Something went wrong.") + } + } +} diff --git a/Mlem/Views/Shared/Composer/PostDetailEditorView+Logic.swift b/Mlem/Views/Shared/Composer/PostDetailEditorView+Logic.swift index e93fd1c46..787f8af72 100644 --- a/Mlem/Views/Shared/Composer/PostDetailEditorView+Logic.swift +++ b/Mlem/Views/Shared/Composer/PostDetailEditorView+Logic.swift @@ -60,12 +60,40 @@ extension PostDetailEditorView { } } - func uploadImage(imageSelection: PhotosPickerItem) { + 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 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: .init(), - imageSelection: imageSelection, + imageModel: imageModel, onUpdate: { newValue in self.imageModel = newValue switch newValue.state { @@ -110,5 +138,8 @@ extension PostDetailEditorView { default: break } + imageSelection = nil + imageModel = nil + postURL = "" } } diff --git a/Mlem/Views/Shared/Composer/PostDetailEditorView.swift b/Mlem/Views/Shared/Composer/PostDetailEditorView.swift index a48c6fb89..8c5d9c034 100644 --- a/Mlem/Views/Shared/Composer/PostDetailEditorView.swift +++ b/Mlem/Views/Shared/Composer/PostDetailEditorView.swift @@ -28,6 +28,8 @@ struct PostDetailEditorView: View { @Dependency(\.pictrsRepository) var pictrsRepository @Dependency(\.errorHandler) var errorHandler + @AppStorage("confirmImageUploads") var confirmImageUploads: Bool = false + @Environment(\.dismiss) var dismiss var community: APICommunity @@ -42,10 +44,10 @@ 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? @@ -110,12 +112,7 @@ struct PostDetailEditorView: View { // URL Row if let imageModel = imageModel { - ImageUploadView(imageModel: imageModel, onCancel: { - cancelUpload() - imageSelection = nil - self.imageModel = nil - postURL = "" - }) + ImageUploadView(imageModel: imageModel, onCancel: cancelUpload) } else { VStack(alignment: .labelStart) { HStack { @@ -136,7 +133,7 @@ struct PostDetailEditorView: View { Button { showingPhotosPicker = true } label: { - Image(systemName: "paperclip") + Image(systemName: Icons.attachment) .font(.title3) .dynamicTypeSize(.medium) } @@ -202,10 +199,16 @@ struct PostDetailEditorView: View { .navigationBarColor() .navigationBarTitleDisplayMode(.inline) .photosPicker(isPresented: $showingPhotosPicker, selection: $imageSelection, matching: .images) - .onChange(of: imageSelection) { newValue in - if let selection = newValue { - uploadImage(imageSelection: selection) - } + .onChange(of: imageSelection) { _ in + loadImage() + } + .sheet(isPresented: $showingUploadConfirmation) { + UploadConfirmationView( + isPresented: $showingUploadConfirmation, + onUpload: uploadImage, + onCancel: cancelUpload, + imageModel: imageModel + ) } } } 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..54b2ab524 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 = false @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,