From 111fabf0b3dbb6359d7740fe31318a3a85ffd4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Sun, 3 May 2026 11:07:14 -0700 Subject: [PATCH] Replace Tenor GIF integration with Purple proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Tenor API client and models; add PurpleGIFAPIClient and a robust GIFModels decoder that supports Tenor and KLIPY payloads. Remove Tenor API key setting and Secrets entry. Update GIF picker to accept a Purple-backed view model, adjust Labs text, and update Xcode project file references. Closes: #3695 Changelog-Changed: Made GIF keyboard widely available to Purple users without need for an API key (Damus Labs) Signed-off-by: Daniel D’Aquino --- damus.xcodeproj/project.pbxproj | 32 +- .../Labs/Views/DamusLabsExperiments.swift | 2 +- .../Settings/Models/UserSettingsStore.swift | 14 +- .../Views/AppearanceSettingsView.swift | 8 - damus/Secrets.swift | 1 - damus/Shared/Media/GIF/GIFModels.swift | 290 ++++++++++++++++++ damus/Shared/Media/GIF/GIFPickerView.swift | 189 +++++++++--- .../Shared/Media/GIF/PurpleGIFAPIClient.swift | 135 ++++++++ damus/Shared/Media/GIF/TenorAPIClient.swift | 112 ------- damus/Shared/Media/GIF/TenorModels.swift | 53 ---- 10 files changed, 596 insertions(+), 240 deletions(-) create mode 100644 damus/Shared/Media/GIF/GIFModels.swift create mode 100644 damus/Shared/Media/GIF/PurpleGIFAPIClient.swift delete mode 100644 damus/Shared/Media/GIF/TenorAPIClient.swift delete mode 100644 damus/Shared/Media/GIF/TenorModels.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 0002692a..fd2bccf5 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -625,15 +625,15 @@ 5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; }; 5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; }; 5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF72FC129B9142F00124A13 /* ShareAction.swift */; }; - 5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; }; - 5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; }; - 5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; }; + 5CFDE6E52EF4F782004E8661 /* GIFModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */; }; + 5CFDE6E62EF4F782004E8661 /* GIFModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */; }; + 5CFDE6E72EF4F782004E8661 /* GIFModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */; }; 5CFDE6ED2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; }; 5CFDE6EE2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; }; 5CFDE6EF2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; }; - 5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; }; - 5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; }; - 5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; }; + 5CFDE6F12EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */; }; + 5CFDE6F22EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */; }; + 5CFDE6F32EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */; }; 5CFDE6F52EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; }; 5CFDE6F62EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; }; 5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; }; @@ -2747,9 +2747,9 @@ 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicView.swift; sourceTree = ""; }; 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = ""; }; 5CF72FC129B9142F00124A13 /* ShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAction.swift; sourceTree = ""; }; - 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorModels.swift; sourceTree = ""; }; + 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFModels.swift; sourceTree = ""; }; 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFPickerView.swift; sourceTree = ""; }; - 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorAPIClient.swift; sourceTree = ""; }; + 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleGIFAPIClient.swift; sourceTree = ""; }; 5CFDE6F42EF4F939004E8661 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; 6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = ""; }; 643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; @@ -5221,9 +5221,9 @@ 5CFDE6E32EF4F773004E8661 /* GIF */ = { isa = PBXGroup; children = ( - 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */, + 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */, 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */, - 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */, + 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */, ); path = GIF; sourceTree = ""; @@ -5983,7 +5983,7 @@ 4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */, B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */, 5C8F97332EB46126009399B1 /* LiveStreamViewers.swift in Sources */, - 5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */, + 5CFDE6F22EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */, 4C5D5C992A6AF8F80024563C /* NdbNote.swift in Sources */, 4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */, D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */, @@ -6393,7 +6393,7 @@ 4C1A9A2129DDD3E100516EAC /* KeySettingsView.swift in Sources */, D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */, 4C011B5E2BD0A56A002F2F9B /* ChatEventView.swift in Sources */, - 5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */, + 5CFDE6E62EF4F782004E8661 /* GIFModels.swift in Sources */, 4C32B95A2A9AD44700DC3548 /* Verifiable.swift in Sources */, 4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */, 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */, @@ -6600,7 +6600,7 @@ 3ACF94482DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */, 82D6FAE92CD99F7900C925F4 /* NewMutesNotify.swift in Sources */, 82D6FAEA2CD99F7900C925F4 /* NewUnmutesNotify.swift in Sources */, - 5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */, + 5CFDE6F12EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */, 82D6FAEB2CD99F7900C925F4 /* Notify.swift in Sources */, 82D6FAEC2CD99F7900C925F4 /* OnlyZapsNotify.swift in Sources */, 82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */, @@ -6926,7 +6926,7 @@ 82D6FC002CD99F7900C925F4 /* ProfilePicturesView.swift in Sources */, 82D6FC012CD99F7900C925F4 /* DamusAppNotificationView.swift in Sources */, 3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */, - 5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */, + 5CFDE6E52EF4F782004E8661 /* GIFModels.swift in Sources */, 82D6FC022CD99F7900C925F4 /* InnerTimelineView.swift in Sources */, 82D6FC032CD99F7900C925F4 /* PostingTimelineView.swift in Sources */, 82D6FC042CD99F7900C925F4 /* ZapsView.swift in Sources */, @@ -7119,7 +7119,7 @@ D73E5E2B2C6A97F4007EB227 /* PostNotify.swift in Sources */, D73E5E2C2C6A97F4007EB227 /* PresentSheetNotify.swift in Sources */, D73E5E2D2C6A97F4007EB227 /* ProfileUpdatedNotify.swift in Sources */, - 5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */, + 5CFDE6F32EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */, 3A515C522DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */, D73E5E2E2C6A97F4007EB227 /* ReportNotify.swift in Sources */, D73E5E2F2C6A97F4007EB227 /* ScrollToTopNotify.swift in Sources */, @@ -7521,7 +7521,7 @@ D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */, D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */, D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */, - 5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */, + 5CFDE6E72EF4F782004E8661 /* GIFModels.swift in Sources */, D703D78A2C670C8A00A400EA /* LibreTranslateServer.swift in Sources */, D703D7602C670AAB00A400EA /* MigratedTypes.swift in Sources */, D73E5F742C6A9890007EB227 /* damusApp.swift in Sources */, diff --git a/damus/Features/Labs/Views/DamusLabsExperiments.swift b/damus/Features/Labs/Views/DamusLabsExperiments.swift index f1c5d9cf..a60b2d18 100644 --- a/damus/Features/Labs/Views/DamusLabsExperiments.swift +++ b/damus/Features/Labs/Views/DamusLabsExperiments.swift @@ -74,7 +74,7 @@ struct DamusLabsExperiments: View { LabsExplainerView( labName: gifs_label, systemImage: "", - labDescription: NSLocalizedString("This will allow you to easily add gifs from Tenor to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show you all of tenor's featured GIFs. You can also search for GIFs.", comment: "Damus Labs feature explanation")) + labDescription: NSLocalizedString("This will allow you to easily add GIFs to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show featured GIFs for Purple subscribers, and you can also search for GIFs.", comment: "Damus Labs feature explanation")) } } } diff --git a/damus/Features/Settings/Models/UserSettingsStore.swift b/damus/Features/Settings/Models/UserSettingsStore.swift index e6f203e1..d80aec2e 100644 --- a/damus/Features/Settings/Models/UserSettingsStore.swift +++ b/damus/Features/Settings/Models/UserSettingsStore.swift @@ -343,15 +343,7 @@ class UserSettingsStore: ObservableObject { } } - var tenor_api_key: String { - get { - return internal_tenor_api_key ?? "" - } - set { - internal_tenor_api_key = newValue == "" ? nil : newValue - } - } - + // These internal keys are necessary because entries in the keychain need to be Optional, // but the translation view needs non-Optional String in order to use them as Bindings. @KeychainStorage(account: "deepl_apikey") @@ -366,9 +358,7 @@ class UserSettingsStore: ObservableObject { @KeychainStorage(account: "libretranslate_apikey") var internal_libretranslate_api_key: String? - @KeychainStorage(account: "tenor_api_key") - var internal_tenor_api_key: String? - + @KeychainStorage(account: "nostr_wallet_connect") var nostr_wallet_connect: String? // TODO: strongly type this to WalletConnectURL diff --git a/damus/Features/Settings/Views/AppearanceSettingsView.swift b/damus/Features/Settings/Views/AppearanceSettingsView.swift index 4a8ec0f1..cac16fe0 100644 --- a/damus/Features/Settings/Views/AppearanceSettingsView.swift +++ b/damus/Features/Settings/Views/AppearanceSettingsView.swift @@ -132,14 +132,6 @@ struct AppearanceSettingsView: View { } } - // MARK: - GIFs - if damus_state.settings.enable_gifs_feature { - Section(NSLocalizedString("GIFs", comment: "Section title for GIFs configuration.")) { - SecureField(NSLocalizedString("Tenor API Key (optional)", comment: "Prompt for optional entry of API Key to use with Tenor."), text: $settings.tenor_api_key) - .disableAutocorrection(true) - .autocapitalization(UITextAutocapitalizationType.none) - } - } // MARK: - Content filters and moderation Section( diff --git a/damus/Secrets.swift b/damus/Secrets.swift index b27a607d..32cb1349 100644 --- a/damus/Secrets.swift +++ b/damus/Secrets.swift @@ -10,5 +10,4 @@ import Foundation enum Secrets { - static let TENOR_API_KEY: String? = ProcessInfo.processInfo.environment["TENOR_API_KEY"] } diff --git a/damus/Shared/Media/GIF/GIFModels.swift b/damus/Shared/Media/GIF/GIFModels.swift new file mode 100644 index 00000000..8a850feb --- /dev/null +++ b/damus/Shared/Media/GIF/GIFModels.swift @@ -0,0 +1,290 @@ +// +// GIFModels.swift +// damus +// + +import Foundation + +struct GIFSearchResponse: Decodable { + let results: [GIFResult] + let next: String? + + /// Decodes GIF payloads from both featured and search endpoints. + /// + /// Featured returns a Tenor-style payload with top-level `results` and `next`. + /// Search returns a KLIPY payload with a nested `data.data` array plus paging metadata. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let results = try container.decodeIfPresent([GIFResult].self, forKey: .results) { + self.results = results + self.next = try container.decodeIfPresent(String.self, forKey: .next) + return + } + + if let searchPayload = try container.decodeIfPresent(KLIPYSearchPayload.self, forKey: .data) { + self.results = searchPayload.data + self.next = searchPayload.has_next ? String(searchPayload.current_page + 1) : nil + return + } + + throw DecodingError.keyNotFound( + CodingKeys.results, + .init( + codingPath: decoder.codingPath, + debugDescription: "No supported GIF results payload was found. Expected either top-level results or nested data.data." + ) + ) + } + + private enum CodingKeys: String, CodingKey { + case results + case next + case data + } + + private struct KLIPYSearchPayload: Decodable { + let data: [GIFResult] + let current_page: Int + let per_page: Int + let has_next: Bool + } +} + +struct GIFResult: Decodable, Identifiable { + let id: String + let title: String? + let media_formats: GIFMediaFormats? + let content_description: String? + let slug: String? + + /// Decodes either featured-style or KLIPY search-style GIF payloads. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try GIFResult.decodeID(from: container) + self.title = try container.decodeIfPresent(String.self, forKey: .title) + self.media_formats = try GIFResult.decodeMediaFormats(from: container) + self.content_description = try container.decodeIfPresent(String.self, forKey: .content_description) + self.slug = try container.decodeIfPresent(String.self, forKey: .slug) + } + + var previewURL: URL? { + guard let url = media_formats?.preview?.url else { return nil } + return URL(string: url) + } + + var fullURL: URL? { + guard let url = media_formats?.primary?.url else { return nil } + return URL(string: url) + } + + var mediumURL: URL? { + guard let url = media_formats?.medium?.url else { return nil } + return URL(string: url) + } + + private enum CodingKeys: String, CodingKey { + case id + case title + case media_formats + case content_description + case slug + case file + } + + /// Decodes GIF IDs that may arrive as strings or numbers. + private static func decodeID(from container: KeyedDecodingContainer) throws -> String { + if let stringID = try? container.decode(String.self, forKey: .id) { + return stringID + } + + if let intID = try? container.decode(Int.self, forKey: .id) { + return String(intID) + } + + if let int64ID = try? container.decode(Int64.self, forKey: .id) { + return String(int64ID) + } + + if let uint64ID = try? container.decode(UInt64.self, forKey: .id) { + return String(uint64ID) + } + + if let doubleID = try? container.decode(Double.self, forKey: .id) { + guard doubleID.isFinite, !doubleID.isNaN else { + throw DecodingError.dataCorruptedError( + forKey: .id, + in: container, + debugDescription: "GIF id must be a finite number." + ) + } + + guard doubleID.rounded() == doubleID else { + throw DecodingError.dataCorruptedError( + forKey: .id, + in: container, + debugDescription: "GIF id numeric value must be integral." + ) + } + + if let exactInt64 = Int64(exactly: doubleID) { + return String(exactInt64) + } + + if let exactUInt64 = UInt64(exactly: doubleID) { + return String(exactUInt64) + } + + throw DecodingError.dataCorruptedError( + forKey: .id, + in: container, + debugDescription: "GIF id numeric value is out of supported range." + ) + } + + throw DecodingError.dataCorruptedError( + forKey: .id, + in: container, + debugDescription: "No supported GIF id value was found." + ) + } + + /// Decodes either shared media formats or KLIPY's nested file formats. + private static func decodeMediaFormats(from container: KeyedDecodingContainer) throws -> GIFMediaFormats? { + if let mediaFormats = try container.decodeIfPresent(GIFMediaFormats.self, forKey: .media_formats) { + return mediaFormats + } + + if let klipyFile = try container.decodeIfPresent(KLIPYFileFormats.self, forKey: .file) { + return klipyFile.asMediaFormats + } + + return nil + } +} + +struct GIFMediaFormats: Decodable { + let gif: GIFMediaFormat? + let webp: GIFMediaFormat? + let jpg: GIFMediaFormat? + let mp4: GIFMediaFormat? + let webm: GIFMediaFormat? + let tinygif: GIFMediaFormat? + let tinymp4: GIFMediaFormat? + let tinywebm: GIFMediaFormat? + + var preview: GIFMediaFormat? { + tinygif ?? tinymp4 ?? tinywebm ?? gif ?? webp ?? jpg ?? mp4 ?? webm + } + + var medium: GIFMediaFormat? { + gif ?? webp ?? mp4 ?? webm ?? jpg + } + + var primary: GIFMediaFormat? { + gif ?? webp ?? mp4 ?? webm ?? jpg + } +} + +struct GIFMediaFormat: Decodable { + let url: String + let dims: [Int]? + let duration: Double? + let size: Int? + + /// Decodes either shared Damus media format fields or KLIPY width/height fields. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.url = try container.decode(String.self, forKey: .url) + self.duration = try container.decodeIfPresent(Double.self, forKey: .duration) + self.size = try container.decodeIfPresent(Int.self, forKey: .size) + + if let dims = try container.decodeIfPresent([Int].self, forKey: .dims) { + self.dims = dims + return + } + + let width = try GIFMediaFormat.decodeIntegerValue(forKey: .width, from: container) + let height = try GIFMediaFormat.decodeIntegerValue(forKey: .height, from: container) + if let width, let height { + self.dims = [width, height] + return + } + + self.dims = nil + } + + var width: Int? { + dims?.count ?? 0 >= 1 ? dims?[0] : nil + } + + var height: Int? { + dims?.count ?? 0 >= 2 ? dims?[1] : nil + } + + private enum CodingKeys: String, CodingKey { + case url + case dims + case duration + case size + case width + case height + } + + /// Decodes integer-like numeric fields that may be emitted as either integers or doubles. + private static func decodeIntegerValue(forKey key: CodingKeys, from container: KeyedDecodingContainer) throws -> Int? { + if let intValue = try? container.decode(Int.self, forKey: key) { + return intValue + } + + guard let doubleValue = try? container.decode(Double.self, forKey: key) else { + return nil + } + + guard doubleValue.isFinite, !doubleValue.isNaN else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "GIF numeric field must be a finite number." + ) + } + + guard doubleValue.rounded() == doubleValue else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "GIF numeric field must be an integral value." + ) + } + + guard let exactInt = Int(exactly: doubleValue) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "GIF numeric field is out of supported range." + ) + } + + return exactInt + } +} + +private struct KLIPYFileFormats: Decodable { + let hd: GIFMediaFormats? + let md: GIFMediaFormats? + let sm: GIFMediaFormats? + let xs: GIFMediaFormats? + + var asMediaFormats: GIFMediaFormats { + sm ?? md ?? hd ?? xs ?? GIFMediaFormats( + gif: nil, + webp: nil, + jpg: nil, + mp4: nil, + webm: nil, + tinygif: nil, + tinymp4: nil, + tinywebm: nil + ) + } +} diff --git a/damus/Shared/Media/GIF/GIFPickerView.swift b/damus/Shared/Media/GIF/GIFPickerView.swift index 68c3581f..43590939 100644 --- a/damus/Shared/Media/GIF/GIFPickerView.swift +++ b/damus/Shared/Media/GIF/GIFPickerView.swift @@ -13,10 +13,17 @@ struct GIFPickerView: View { let damus_state: DamusState let onGIFSelected: (URL) -> Void - @StateObject private var viewModel = GIFPickerViewModel() + @StateObject private var viewModel: GIFPickerViewModel @State private var searchText: String = "" @FocusState private var isSearchFocused: Bool + /// Creates a GIF picker bound to the current Damus state. + init(damus_state: DamusState, onGIFSelected: @escaping (URL) -> Void) { + self.damus_state = damus_state + self.onGIFSelected = onGIFSelected + _viewModel = StateObject(wrappedValue: GIFPickerViewModel(purple: damus_state.purple)) + } + var body: some View { NavigationView { VStack(spacing: 0) { @@ -59,7 +66,7 @@ struct GIFPickerView: View { HStack { Image("search") .foregroundColor(.gray) - TextField(NSLocalizedString("Search GIFs...", comment: "Placeholder for GIF search field"), text: $searchText) + TextField(NSLocalizedString("Search KLIPY", comment: "Placeholder for GIF search field"), text: $searchText) .autocorrectionDisabled(true) .textInputAutocapitalization(.never) .focused($isSearchFocused) @@ -119,27 +126,22 @@ struct GIFPickerView: View { } } - private func errorView(_ error: String) -> some View { + private func errorView(_ error: ErrorView.UserPresentableError) -> some View { VStack { - Spacer() - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.secondary) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding() + ErrorView(damus_state: damus_state, error: error) + Button(NSLocalizedString("Try Again", comment: "Button to retry loading GIFs")) { Task { - if searchText.isEmpty { + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { await viewModel.loadFeatured() - } else { - viewModel.search(query: searchText) + return } + + viewModel.search(query: searchText) } } .buttonStyle(.bordered) - Spacer() + .padding(.bottom, 20) } } @@ -158,7 +160,7 @@ struct GIFPickerView: View { } struct GIFThumbnailView: View { - let gif: TenorGIFResult + let gif: GIFResult let disable_animation: Bool var body: some View { @@ -187,37 +189,55 @@ struct GIFThumbnailView: View { @MainActor class GIFPickerViewModel: ObservableObject { - @Published var gifs: [TenorGIFResult] = [] + @Published var gifs: [GIFResult] = [] @Published var isLoading: Bool = false - @Published var error: String? = nil + @Published var error: ErrorView.UserPresentableError? = nil - private let api = TenorAPIClient() + private let api: PurpleGIFAPIClient + private let featuredPageSize = 30 + private let searchPageSize = 30 private var currentQuery: String? + private var pendingQuery: String? private var nextPos: String? + private var currentPage = 1 + private var hasMoreSearchResults = false private var searchTask: Task? + /// Initializes a GIF picker view model backed by Purple. + init(purple: DamusPurple) { + self.api = PurpleGIFAPIClient(purple: purple) + } + + /// Loads featured GIFs for the initial picker state. func loadFeatured() async { guard !isLoading else { return } isLoading = true error = nil currentQuery = nil + currentPage = 1 + hasMoreSearchResults = false do { - let response = try await api.fetchFeatured() + let response = try await api.fetchFeatured(limit: featuredPageSize) gifs = response.results nextPos = response.next } catch { - self.error = error.localizedDescription + self.error = makePresentableError(from: error, action: "loading featured GIFs") } isLoading = false + await runPendingQueryIfNeeded() } + /// Starts a debounced GIF search. func search(query: String) { searchTask?.cancel() - guard !query.isEmpty else { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + pendingQuery = trimmedQuery.isEmpty ? nil : trimmedQuery + + guard !trimmedQuery.isEmpty else { Task { await loadFeatured() } return } @@ -225,45 +245,95 @@ class GIFPickerViewModel: ObservableObject { searchTask = Task { try? await Task.sleep(nanoseconds: 300_000_000) guard !Task.isCancelled else { return } - await performSearch(query: query) + await performSearch(query: trimmedQuery) } } + /// Performs a GIF search request. private func performSearch(query: String) async { - guard !isLoading else { return } + guard !isLoading else { + pendingQuery = query + return + } isLoading = true error = nil currentQuery = query + pendingQuery = nil + currentPage = 1 nextPos = nil + hasMoreSearchResults = false do { - let response = try await api.search(query: query) + let response = try await api.search(query: query, page: currentPage, perPage: searchPageSize) gifs = response.results - nextPos = response.next + hasMoreSearchResults = response.results.count >= searchPageSize } catch { - self.error = error.localizedDescription + self.error = makePresentableError(from: error, action: "searching GIFs") } isLoading = false + await runPendingQueryIfNeeded() } + /// Converts technical GIF loading errors into user-presentable content. + private func makePresentableError(from error: Error, action: String) -> ErrorView.UserPresentableError { + if let presentableError = error as? ErrorView.UserPresentableErrorProtocol { + return presentableError.userPresentableError + } + + if let gifError = error as? PurpleGIFAPIError { + return gifError.userPresentableError(action: action, currentQuery: currentQuery) + } + + return .init( + user_visible_description: NSLocalizedString("We couldn't load GIFs right now.", comment: "Fallback error shown when the GIF picker fails unexpectedly."), + tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Fallback advice shown when the GIF picker fails unexpectedly."), + technical_info: "GIF picker error while \(action): \(String(describing: error))" + ) + } + + /// Runs the latest queued search after the current load completes. + private func runPendingQueryIfNeeded() async { + guard !isLoading else { return } + guard let pendingQuery, pendingQuery != currentQuery else { return } + + self.pendingQuery = nil + await performSearch(query: pendingQuery) + } + + /// Loads the next page of GIF results. func loadMore() async { - guard !isLoading, let nextPos else { return } + guard !isLoading else { return } + + if let query = currentQuery { + guard hasMoreSearchResults else { return } + + isLoading = true + + do { + let nextPage = currentPage + 1 + let response = try await api.search(query: query, page: nextPage, perPage: searchPageSize) + gifs.append(contentsOf: response.results) + currentPage = nextPage + hasMoreSearchResults = response.results.count >= searchPageSize + } catch { + print("Failed to load more GIFs: \(error)") + } + + isLoading = false + return + } + + guard let nextPos else { return } isLoading = true do { - let response: TenorSearchResponse - if let query = currentQuery { - response = try await api.search(query: query, pos: nextPos) - } else { - response = try await api.fetchFeatured(pos: nextPos) - } + let response = try await api.fetchFeatured(limit: featuredPageSize, pos: nextPos) gifs.append(contentsOf: response.results) self.nextPos = response.next } catch { - // Don't show error for pagination failures print("Failed to load more GIFs: \(error)") } @@ -271,10 +341,55 @@ class GIFPickerViewModel: ObservableObject { } } +private extension PurpleGIFAPIError { + /// Converts Purple GIF API failures into a reusable user-presentable error. + func userPresentableError(action: String, currentQuery: String?) -> ErrorView.UserPresentableError { + let queryContext = currentQuery.map { "query=\($0)" } ?? "featured" + + switch self { + case .unauthorized: + return .init( + user_visible_description: NSLocalizedString("You need an active Purple subscription to use GIF search.", comment: "Error shown when the user is not authorized to use the GIF picker."), + tip: NSLocalizedString("Make sure you're signed in with the right account and that your Purple subscription is active, then try again.", comment: "Advice shown when GIF picker access is denied."), + technical_info: "GIF picker unauthorized while \(action); context=\(queryContext)" + ) + case .invalidURL: + return .init( + user_visible_description: NSLocalizedString("The GIF service is misconfigured.", comment: "Error shown when the GIF picker generated an invalid URL."), + tip: NSLocalizedString("Try again later. If this keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker URL is invalid."), + technical_info: "GIF picker invalid URL while \(action); context=\(queryContext)" + ) + case .invalidResponse: + return .init( + user_visible_description: NSLocalizedString("The GIF service returned an unexpected response.", comment: "Error shown when the GIF picker receives an invalid server response."), + tip: NSLocalizedString("Try again in a moment. If it keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker receives an invalid server response."), + technical_info: "GIF picker invalid response while \(action); context=\(queryContext)" + ) + case .decodingError(let decodingError, let rawResponse): + let responseText = rawResponse ?? "" + return .init( + user_visible_description: NSLocalizedString("We couldn't understand the GIF data from the server.", comment: "Error shown when GIF response parsing fails."), + tip: NSLocalizedString("Try again in a moment. If the problem continues, copy the technical information and send it to support.", comment: "Advice shown when GIF response parsing fails."), + technical_info: "GIF picker decoding error while \(action); context=\(queryContext); error=\(String(describing: decodingError)); response=\(responseText)" + ) + case .networkError(let networkError): + return .init( + user_visible_description: NSLocalizedString("We couldn't reach the GIF service.", comment: "Error shown when GIF loading fails because of a network issue."), + tip: NSLocalizedString("Check your internet connection and try again.", comment: "Advice shown when GIF loading fails because of a network issue."), + technical_info: "GIF picker network error while \(action); context=\(queryContext); error=\(String(describing: networkError))" + ) + case .upstreamError(let statusCode, let message): + return .init( + user_visible_description: NSLocalizedString("The GIF service is temporarily unavailable.", comment: "Error shown when the upstream GIF service fails."), + tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the upstream GIF service fails."), + technical_info: "GIF picker upstream error while \(action); context=\(queryContext); status=\(statusCode); message=\(message ?? "none")" + ) + } + } +} + #Preview { GIFPickerView(damus_state: test_damus_state) { url in print("Selected GIF: \(url)") } } - - diff --git a/damus/Shared/Media/GIF/PurpleGIFAPIClient.swift b/damus/Shared/Media/GIF/PurpleGIFAPIClient.swift new file mode 100644 index 00000000..3e5ffe98 --- /dev/null +++ b/damus/Shared/Media/GIF/PurpleGIFAPIClient.swift @@ -0,0 +1,135 @@ +// +// PurpleGIFAPIClient.swift +// damus +// + +import Foundation + +enum PurpleGIFAPIError: Error, LocalizedError { + case invalidURL + case invalidResponse + case unauthorized + case upstreamError(statusCode: Int, message: String?) + case networkError(Error) + case decodingError(Error, rawResponse: String?) + + var errorDescription: String? { + switch self { + case .invalidURL: + return NSLocalizedString("Invalid GIF service URL", comment: "Error message for invalid Purple GIF URL") + case .invalidResponse: + return NSLocalizedString("Invalid response from GIF service", comment: "Error message for invalid Purple GIF response") + case .unauthorized: + return NSLocalizedString("Purple subscription required to use GIF search", comment: "Error message shown when the user is not authorized to use Purple GIF endpoints") + case .upstreamError(_, let message): + return message ?? NSLocalizedString("GIF service is temporarily unavailable", comment: "Error message shown when the Purple GIF service fails") + case .networkError(let error): + return error.localizedDescription + case .decodingError: + return NSLocalizedString("Failed to parse GIF data", comment: "Error message for GIF decoding failure") + } + } +} + +actor PurpleGIFAPIClient { + /// The Purple proxy used to access KLIPY GIF endpoints. + let purple: DamusPurple + private let decoder = JSONDecoder() + + /// Initializes a Purple-backed GIF API client. + init(purple: DamusPurple) { + self.purple = purple + } + + /// Fetches featured GIFs from the Purple KLIPY proxy. + func fetchFeatured(limit: Int = 30, pos: String? = nil) async throws -> GIFSearchResponse { + var url = purple.environment.api_base_url() + url.append(path: "/gifs/featured") + url.append(queryItems: [ + .init(name: "limit", value: String(limit)) + ]) + + if let pos, !pos.isEmpty { + url.append(queryItems: [.init(name: "pos", value: pos)]) + } + + return try await performRequest(url: url) + } + + /// Searches GIFs from the Purple KLIPY proxy. + func search(query: String, page: Int = 1, perPage: Int = 30) async throws -> GIFSearchResponse { + var url = purple.environment.api_base_url() + url.append(path: "/gifs/search") + url.append(queryItems: [ + .init(name: "q", value: query), + .init(name: "page", value: String(page)), + .init(name: "per_page", value: String(perPage)) + ]) + + return try await performRequest(url: url) + } + + /// Performs an authenticated request against the Purple GIF proxy. + private func performRequest(url: URL) async throws -> GIFSearchResponse { + do { + let (data, response) = try await make_nip98_authenticated_request( + method: .get, + url: url, + payload: nil, + payload_type: nil, + auth_keypair: purple.keypair + ) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PurpleGIFAPIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw mapHTTPError(statusCode: httpResponse.statusCode, data: data) + } + + do { + return try decoder.decode(GIFSearchResponse.self, from: data) + } catch let decodingError as DecodingError { + let rawResponse = String(data: data, encoding: .utf8) + throw PurpleGIFAPIError.decodingError(decodingError, rawResponse: rawResponse) + } + } catch let error as PurpleGIFAPIError { + throw error + } catch { + throw PurpleGIFAPIError.networkError(error) + } + } + + /// Maps HTTP failures from the Purple proxy into localized client errors. + private func mapHTTPError(statusCode: Int, data: Data) -> PurpleGIFAPIError { + if statusCode == 401 { + return .unauthorized + } + + let message = extractErrorMessage(from: data) + return .upstreamError(statusCode: statusCode, message: message) + } + + /// Extracts a human-readable error message from a JSON or text payload. + private func extractErrorMessage(from data: Data) -> String? { + guard !data.isEmpty else { + return nil + } + + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let error = json["error"] as? String, !error.isEmpty { + return error + } + if let message = json["message"] as? String, !message.isEmpty { + return message + } + } + + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { + return nil + } + + return text + } +} diff --git a/damus/Shared/Media/GIF/TenorAPIClient.swift b/damus/Shared/Media/GIF/TenorAPIClient.swift deleted file mode 100644 index 2863f184..00000000 --- a/damus/Shared/Media/GIF/TenorAPIClient.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// TenorAPIClient.swift -// damus -// -// Created by eric on 12/11/25. -// - -import Foundation - -enum TenorAPIError: Error, LocalizedError { - case invalidURL - case networkError(Error) - case decodingError(Error) - case missingAPIKey - case invalidResponse - - var errorDescription: String? { - switch self { - case .invalidURL: - return NSLocalizedString("Invalid URL", comment: "Error message for invalid Tenor URL") - case .networkError(let error): - return error.localizedDescription - case .decodingError: - return NSLocalizedString("Failed to parse GIF data", comment: "Error message for Tenor decoding failure") - case .missingAPIKey: - return NSLocalizedString("Tenor API key not configured", comment: "Error message for missing Tenor API key") - case .invalidResponse: - return NSLocalizedString("Invalid response from server", comment: "Error message for invalid Tenor response") - } - } -} - -actor TenorAPIClient { - private let baseURL = "https://tenor.googleapis.com/v2" - private let decoder = JSONDecoder() - - private var apiKey: String? { - let userKey = UserSettingsStore.shared?.tenor_api_key - if let userKey, !userKey.isEmpty { - return userKey - } - return Secrets.TENOR_API_KEY - } - - func fetchFeatured(limit: Int = 30, pos: String? = nil) async throws -> TenorSearchResponse { - guard let apiKey else { - throw TenorAPIError.missingAPIKey - } - - var components = URLComponents(string: "\(baseURL)/featured") - components?.queryItems = [ - URLQueryItem(name: "key", value: apiKey), - URLQueryItem(name: "limit", value: String(limit)), - URLQueryItem(name: "media_filter", value: "gif,mediumgif,tinygif"), - URLQueryItem(name: "contentfilter", value: "medium") - ] - - if let pos { - components?.queryItems?.append(URLQueryItem(name: "pos", value: pos)) - } - - guard let url = components?.url else { - throw TenorAPIError.invalidURL - } - - return try await performRequest(url: url) - } - - func search(query: String, limit: Int = 30, pos: String? = nil) async throws -> TenorSearchResponse { - guard let apiKey else { - throw TenorAPIError.missingAPIKey - } - - var components = URLComponents(string: "\(baseURL)/search") - components?.queryItems = [ - URLQueryItem(name: "q", value: query), - URLQueryItem(name: "key", value: apiKey), - URLQueryItem(name: "limit", value: String(limit)), - URLQueryItem(name: "media_filter", value: "gif,mediumgif,tinygif"), - URLQueryItem(name: "contentfilter", value: "medium") - ] - - if let pos { - components?.queryItems?.append(URLQueryItem(name: "pos", value: pos)) - } - - guard let url = components?.url else { - throw TenorAPIError.invalidURL - } - - return try await performRequest(url: url) - } - - private func performRequest(url: URL) async throws -> TenorSearchResponse { - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - throw TenorAPIError.invalidResponse - } - - return try decoder.decode(TenorSearchResponse.self, from: data) - } catch let error as TenorAPIError { - throw error - } catch let error as DecodingError { - throw TenorAPIError.decodingError(error) - } catch { - throw TenorAPIError.networkError(error) - } - } -} diff --git a/damus/Shared/Media/GIF/TenorModels.swift b/damus/Shared/Media/GIF/TenorModels.swift deleted file mode 100644 index 4e2f028b..00000000 --- a/damus/Shared/Media/GIF/TenorModels.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// TenorModels.swift -// damus -// -// Created by eric on 12/11/25. -// - -import Foundation - -struct TenorSearchResponse: Codable { - let results: [TenorGIFResult] - let next: String? -} - -struct TenorGIFResult: Codable, Identifiable { - let id: String - let title: String - let media_formats: TenorMediaFormats - let content_description: String? - - var previewURL: URL? { - URL(string: media_formats.tinygif.url) - } - - var fullURL: URL? { - URL(string: media_formats.gif.url) - } - - var mediumURL: URL? { - URL(string: media_formats.mediumgif.url) - } -} - -struct TenorMediaFormats: Codable { - let gif: TenorMediaFormat - let mediumgif: TenorMediaFormat - let tinygif: TenorMediaFormat -} - -struct TenorMediaFormat: Codable { - let url: String - let dims: [Int] - let duration: Double? - let size: Int? - - var width: Int? { - dims.count >= 1 ? dims[0] : nil - } - - var height: Int? { - dims.count >= 2 ? dims[1] : nil - } -}