diff --git a/CHANGELOG.md b/CHANGELOG.md index 631f878af..deefb70c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed a bug where toggles in the settings screen were white instead of green when toggled on. [#1251](https://github.com/planetary-social/nos/issues/1251) - Added routing to profile when tapping on follow notification. [#1447](https://github.com/planetary-social/nos/issues/1447) - Localized follows notifications. [#1446](https://github.com/planetary-social/nos/issues/1446) +- Fixed alert when uploading big files suggesting users pay for nostr.build. [#1321](https://github.com/planetary-social/nos/issues/1321) ### Internal Changes - Use NIP-92 media metadata to display media in the proper orientation. Currently behind the “Enable new media display” feature flag. [#1172](https://github.com/planetary-social/nos/issues/1172) diff --git a/Nos/Assets/Localization/ImagePicker.xcstrings b/Nos/Assets/Localization/ImagePicker.xcstrings index 54f1aebef..302fb78f8 100644 --- a/Nos/Assets/Localization/ImagePicker.xcstrings +++ b/Nos/Assets/Localization/ImagePicker.xcstrings @@ -16,6 +16,12 @@ "value" : "Camera" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cámara" + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -57,6 +63,12 @@ "value" : "Camera is not available on this device" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cámara no esta disponible en este aparato" + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -98,6 +110,12 @@ "value" : "Error uploading the file" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error subiendo el archivo" + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -124,6 +142,40 @@ } } }, + "errorUploadingFileExceedsLimit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The max file size for free media uploads is %@. To upload large files, upgrade to a Nostr.build Professional account." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El maximo tamaño para publicacion de achivos gratis es %@. Para subir archives mas grande puedes pagar para subscribir a una cuenta Nostr.build pro." + } + } + } + }, + "errorUploadingFileExceedsSizeLimit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Get a Nostr.build account to upload larger files" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registarse para una cuenta pro de Nostr.build para la abilidad de subir archivos mas grande" + } + } + } + }, "errorUploadingFileMessage" : { "extractionState" : "manual", "localizations" : { @@ -139,6 +191,12 @@ "value" : "An error was encountered when uploading the file you provided. Please try again." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hubo un error en el intento de subir archivos. Porfavor intenta de vuelta." + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -173,6 +231,29 @@ "state" : "translated", "value" : "An error was encountered when uploading the file you provided. The message was: \"%@\"" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hubo un error en el intento de subir el archivo. El mensaje de error era\"%@\"" + } + } + } + }, + "getAccount" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Get Account" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registar una Cuenta" + } } } }, @@ -191,6 +272,12 @@ "value" : "You can allow camera permissions by opening the Settings app." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puedes dar permiso a aceder la cámara en el app de configuraciones." + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -232,6 +319,12 @@ "value" : "Permissions required for %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prescisa permiso para %@" + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -273,6 +366,12 @@ "value" : "Photo Library" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bibiloteca de Fotos" + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -314,6 +413,12 @@ "value" : "Select from Photo Library" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elijir fotos en la biblioteca" + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -366,6 +471,12 @@ "value" : "Take photo or video" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sacar un foto o video" + } + }, "fa" : { "stringUnit" : { "state" : "needs_review", @@ -407,6 +518,12 @@ "value" : "Uploading..." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subiendo... " + } + }, "fa" : { "stringUnit" : { "state" : "translated", diff --git a/Nos/Service/FileStorage/FileStorageAPIClient.swift b/Nos/Service/FileStorage/FileStorageAPIClient.swift index 5fece8d55..b9dd6122d 100644 --- a/Nos/Service/FileStorage/FileStorageAPIClient.swift +++ b/Nos/Service/FileStorage/FileStorageAPIClient.swift @@ -24,6 +24,7 @@ enum FileStorageAPIClientError: Error { case invalidResponseURL(String) case invalidURLRequest case missingKeyPair + case fileTooBig(String?) case uploadFailed(String?) } @@ -53,8 +54,9 @@ class NostrBuildAPIClient: FileStorageAPIClient { assert(fileURL.isFileURL, "The URL must point to a file.") let apiURL = try await apiURL() let (request, data) = try uploadRequest(fileAt: fileURL, isProfilePhoto: isProfilePhoto, apiURL: apiURL) - let (responseData, _) = try await URLSession.shared.upload(for: request, from: data) - return try assetURL(from: responseData) + let (responseData, response) = try await URLSession.shared.upload(for: request, from: data) + /// Attempt to retrieve the asset URL from the response data and HTTP response. + return try assetURL(from: responseData, response: response as? HTTPURLResponse) } // MARK: - Internal @@ -74,19 +76,29 @@ class NostrBuildAPIClient: FileStorageAPIClient { } /// The URL of the uploaded asset parsed from the API's response. - private func assetURL(from responseData: Data) throws -> URL { - let response = try decoder.decode(FileStorageUploadResponseJSON.self, from: responseData) - guard let urlString = response.nip94Event?.urlString else { - throw FileStorageAPIClientError.uploadFailed(response.message) + private func assetURL(from responseData: Data, response: HTTPURLResponse?) throws -> URL { + let decodedResponse = try decoder.decode(FileStorageUploadResponseJSON.self, from: responseData) + + guard let urlString = decodedResponse.nip94Event?.urlString else { + // Assign an empty string if the response message is nil. + let message = decodedResponse.message ?? "" + + // Checks if the response contains a status code of `413 Payload Too Large`. + if let errorCode = response?.statusCode, errorCode == 413 { + // Verify if the error message indicates the file size exceeds the limit. + let fileSizeLimit = fileSizeLimit(from: message) + throw FileStorageAPIClientError.fileTooBig(fileSizeLimit) + } + // Throw an error indicating the upload failed with the provided message. + throw FileStorageAPIClientError.uploadFailed(message) } + guard let url = URL(string: urlString) else { throw FileStorageAPIClientError.invalidResponseURL(urlString) } return url } - // MARK: - Internal - /// Fetches server info from the file storage API. /// - Returns: the decoded JSON containing server info for the file storage API. func fetchServerInfo() async throws -> FileStorageServerInfoResponseJSON { @@ -102,6 +114,19 @@ class NostrBuildAPIClient: FileStorageAPIClient { throw FileStorageAPIClientError.decodingError } } + + /// Gets the file size limit from the error message. + /// - Parameter message: The error message from nostr.build. + /// - Returns: The file size limit from the error message. + func fileSizeLimit(from message: String) -> String? { + let pattern = /File size exceeds the limit of (\d*\.\d* [MKGT]B)/ + + guard let match = message.firstMatch(of: pattern) else { + return nil + } + + return String(match.1) + } /// Creates a URLRequest and Data from a file URL to be uploaded to the file storage API. func uploadRequest(fileAt fileURL: URL, isProfilePhoto: Bool, apiURL: URL) throws -> (URLRequest, Data) { diff --git a/Nos/Views/NoteComposer/ComposerActionBar.swift b/Nos/Views/NoteComposer/ComposerActionBar.swift index 4573ff0cd..e8292f975 100644 --- a/Nos/Views/NoteComposer/ComposerActionBar.swift +++ b/Nos/Views/NoteComposer/ComposerActionBar.swift @@ -28,6 +28,8 @@ struct ComposerActionBar: View { @State private var alert: AlertState? fileprivate enum AlertAction { + case cancel + case getAccount } var backArrow: some View { @@ -68,7 +70,15 @@ struct ComposerActionBar: View { .onChange(of: expirationTime) { _, _ in subMenu = .none } - .alert(unwrapping: $alert) { (_: AlertAction?) in + .alert(unwrapping: $alert) { action in + switch action { + case .getAccount: + if let url = URL(string: "https://nostr.build/plans/") { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + default: + break + } } .background( LinearGradient( @@ -152,7 +162,9 @@ struct ComposerActionBar: View { /// Uploads an image at the given URL to a file storage service. /// - Parameter imageURL: File URL of the image the user wants to upload. - private func uploadImage(at imageURL: URL) async { + private func uploadImage( + at imageURL: URL + ) async { do { startUploadingImage() let url = try await fileStorageAPIClient.upload(fileAt: imageURL, isProfilePhoto: false) @@ -161,17 +173,7 @@ struct ComposerActionBar: View { } catch { endUploadingImage() - alert = AlertState { - TextState(String(localized: .imagePicker.errorUploadingFile)) - } message: { - if case let FileStorageAPIClientError.uploadFailed(message) = error, let message { - TextState( - String(localized: .imagePicker.errorUploadingFileWithMessage(message)) - ) - } else { - TextState(String(localized: .imagePicker.errorUploadingFileMessage)) - } - } + alert = createAlert(for: error) } } @@ -183,6 +185,44 @@ struct ComposerActionBar: View { self.isUploadingImage = false self.subMenu = .none } + + /// Creates an alert based on the error + private func createAlert( + for error: Error + ) -> AlertState { + var title = String(localized: .imagePicker.errorUploadingFile) + var message: String + var buttons: [ButtonState] = [ + .default( + TextState(String(localized: .localizable.ok)), + action: .send(.cancel) + ) + ] + + if case let FileStorageAPIClientError.fileTooBig(errorMessage) = error, let errorMessage { + title = String(localized: .imagePicker.errorUploadingFileExceedsSizeLimit) + message = String(localized: .imagePicker.errorUploadingFileExceedsLimit(errorMessage)) + buttons = [ + .cancel( + TextState(String(localized: .localizable.cancel)), action: .send(.cancel) + ), + .default( + TextState(String(localized: .imagePicker.getAccount)), + action: .send(.getAccount) + ) + ] + } else if case let FileStorageAPIClientError.uploadFailed(errorMessage) = error, let errorMessage { + message = String(localized: .imagePicker.errorUploadingFileWithMessage(errorMessage)) + } else { + message = String(localized: .imagePicker.errorUploadingFileMessage) + } + + return AlertState( + title: TextState(title), + message: TextState(message), + buttons: buttons + ) + } } struct ComposerActionBar_Previews: PreviewProvider { diff --git a/NosTests/Service/FileStorage/NostrBuildAPIClientTests.swift b/NosTests/Service/FileStorage/NostrBuildAPIClientTests.swift index bd1e3087b..55c8c2817 100644 --- a/NosTests/Service/FileStorage/NostrBuildAPIClientTests.swift +++ b/NosTests/Service/FileStorage/NostrBuildAPIClientTests.swift @@ -40,6 +40,42 @@ class NostrBuildAPIClientTests: XCTestCase { } } + func test_fileSizeLimit_returns_limit() { + // Arrange + let subject = NostrBuildAPIClient() + let nostrBuildErrorMessage = "File size exceeds the limit of 25.00 MB" + + // Act + let result = subject.fileSizeLimit(from: nostrBuildErrorMessage) + + // Assert + XCTAssertEqual(result, "25.00 MB") + } + + func test_fileSizeLimit_returns_limit_15() { + // Arrange + let subject = NostrBuildAPIClient() + let nostrBuildErrorMessage = "File size exceeds the limit of 15.00 MB" + + // Act + let result = subject.fileSizeLimit(from: nostrBuildErrorMessage) + + // Assert + XCTAssertEqual(result, "15.00 MB") + } + + func test_fileSizeLimit_returns_nil_when_message_does_not_match() { + // Arrange + let subject = NostrBuildAPIClient() + let nostrBuildErrorMessage = "File size limit is 25.00 MB" + + // Act + let result = subject.fileSizeLimit(from: nostrBuildErrorMessage) + + // Assert + XCTAssertNil(result) + } + func test_upload_throws_error_when_serverInfo_has_invalid_apiUrl() async throws { // Arrange let subject = NostrBuildAPIClient()