Replace Tenor GIF integration with Purple proxy

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 <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-05-04 11:39:15 -07:00
parent 9dc9738377
commit 111fabf0b3
10 changed files with 596 additions and 240 deletions
+16 -16
View File
@@ -625,15 +625,15 @@
5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; }; 5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; };
5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; }; 5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; };
5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF72FC129B9142F00124A13 /* ShareAction.swift */; }; 5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF72FC129B9142F00124A13 /* ShareAction.swift */; };
5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; }; 5CFDE6E52EF4F782004E8661 /* GIFModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */; };
5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; }; 5CFDE6E62EF4F782004E8661 /* GIFModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */; };
5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; }; 5CFDE6E72EF4F782004E8661 /* GIFModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */; };
5CFDE6ED2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; }; 5CFDE6ED2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
5CFDE6EE2EF4F7B2004E8661 /* 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 */; }; 5CFDE6EF2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; }; 5CFDE6F12EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */; };
5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; }; 5CFDE6F22EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */; };
5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; }; 5CFDE6F32EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */; };
5CFDE6F52EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; }; 5CFDE6F52EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; };
5CFDE6F62EF4F93D004E8661 /* 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 */; }; 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 = "<group>"; }; 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicView.swift; sourceTree = "<group>"; };
5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = "<group>"; }; 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = "<group>"; };
5CF72FC129B9142F00124A13 /* ShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAction.swift; sourceTree = "<group>"; }; 5CF72FC129B9142F00124A13 /* ShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAction.swift; sourceTree = "<group>"; };
5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorModels.swift; sourceTree = "<group>"; }; 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFModels.swift; sourceTree = "<group>"; };
5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFPickerView.swift; sourceTree = "<group>"; }; 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFPickerView.swift; sourceTree = "<group>"; };
5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorAPIClient.swift; sourceTree = "<group>"; }; 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleGIFAPIClient.swift; sourceTree = "<group>"; };
5CFDE6F42EF4F939004E8661 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; }; 5CFDE6F42EF4F939004E8661 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = "<group>"; };
6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = "<group>"; }; 6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = "<group>"; };
643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; }; 643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; };
@@ -5221,9 +5221,9 @@
5CFDE6E32EF4F773004E8661 /* GIF */ = { 5CFDE6E32EF4F773004E8661 /* GIF */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */, 5CFDE6F02EF4F7E2004E8661 /* PurpleGIFAPIClient.swift */,
5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */, 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */,
5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */, 5CFDE6E42EF4F77E004E8661 /* GIFModels.swift */,
); );
path = GIF; path = GIF;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -5983,7 +5983,7 @@
4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */, 4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */,
B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */, B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */,
5C8F97332EB46126009399B1 /* LiveStreamViewers.swift in Sources */, 5C8F97332EB46126009399B1 /* LiveStreamViewers.swift in Sources */,
5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */, 5CFDE6F22EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */,
4C5D5C992A6AF8F80024563C /* NdbNote.swift in Sources */, 4C5D5C992A6AF8F80024563C /* NdbNote.swift in Sources */,
4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */, 4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */,
D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */, D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */,
@@ -6393,7 +6393,7 @@
4C1A9A2129DDD3E100516EAC /* KeySettingsView.swift in Sources */, 4C1A9A2129DDD3E100516EAC /* KeySettingsView.swift in Sources */,
D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */, D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */,
4C011B5E2BD0A56A002F2F9B /* ChatEventView.swift in Sources */, 4C011B5E2BD0A56A002F2F9B /* ChatEventView.swift in Sources */,
5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */, 5CFDE6E62EF4F782004E8661 /* GIFModels.swift in Sources */,
4C32B95A2A9AD44700DC3548 /* Verifiable.swift in Sources */, 4C32B95A2A9AD44700DC3548 /* Verifiable.swift in Sources */,
4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */, 4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */,
501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */, 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */,
@@ -6600,7 +6600,7 @@
3ACF94482DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */, 3ACF94482DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */,
82D6FAE92CD99F7900C925F4 /* NewMutesNotify.swift in Sources */, 82D6FAE92CD99F7900C925F4 /* NewMutesNotify.swift in Sources */,
82D6FAEA2CD99F7900C925F4 /* NewUnmutesNotify.swift in Sources */, 82D6FAEA2CD99F7900C925F4 /* NewUnmutesNotify.swift in Sources */,
5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */, 5CFDE6F12EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */,
82D6FAEB2CD99F7900C925F4 /* Notify.swift in Sources */, 82D6FAEB2CD99F7900C925F4 /* Notify.swift in Sources */,
82D6FAEC2CD99F7900C925F4 /* OnlyZapsNotify.swift in Sources */, 82D6FAEC2CD99F7900C925F4 /* OnlyZapsNotify.swift in Sources */,
82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */, 82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */,
@@ -6926,7 +6926,7 @@
82D6FC002CD99F7900C925F4 /* ProfilePicturesView.swift in Sources */, 82D6FC002CD99F7900C925F4 /* ProfilePicturesView.swift in Sources */,
82D6FC012CD99F7900C925F4 /* DamusAppNotificationView.swift in Sources */, 82D6FC012CD99F7900C925F4 /* DamusAppNotificationView.swift in Sources */,
3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */, 3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */,
5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */, 5CFDE6E52EF4F782004E8661 /* GIFModels.swift in Sources */,
82D6FC022CD99F7900C925F4 /* InnerTimelineView.swift in Sources */, 82D6FC022CD99F7900C925F4 /* InnerTimelineView.swift in Sources */,
82D6FC032CD99F7900C925F4 /* PostingTimelineView.swift in Sources */, 82D6FC032CD99F7900C925F4 /* PostingTimelineView.swift in Sources */,
82D6FC042CD99F7900C925F4 /* ZapsView.swift in Sources */, 82D6FC042CD99F7900C925F4 /* ZapsView.swift in Sources */,
@@ -7119,7 +7119,7 @@
D73E5E2B2C6A97F4007EB227 /* PostNotify.swift in Sources */, D73E5E2B2C6A97F4007EB227 /* PostNotify.swift in Sources */,
D73E5E2C2C6A97F4007EB227 /* PresentSheetNotify.swift in Sources */, D73E5E2C2C6A97F4007EB227 /* PresentSheetNotify.swift in Sources */,
D73E5E2D2C6A97F4007EB227 /* ProfileUpdatedNotify.swift in Sources */, D73E5E2D2C6A97F4007EB227 /* ProfileUpdatedNotify.swift in Sources */,
5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */, 5CFDE6F32EF4F7EA004E8661 /* PurpleGIFAPIClient.swift in Sources */,
3A515C522DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */, 3A515C522DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */,
D73E5E2E2C6A97F4007EB227 /* ReportNotify.swift in Sources */, D73E5E2E2C6A97F4007EB227 /* ReportNotify.swift in Sources */,
D73E5E2F2C6A97F4007EB227 /* ScrollToTopNotify.swift in Sources */, D73E5E2F2C6A97F4007EB227 /* ScrollToTopNotify.swift in Sources */,
@@ -7521,7 +7521,7 @@
D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */, D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */,
D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */, D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */,
D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */, D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */,
5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */, 5CFDE6E72EF4F782004E8661 /* GIFModels.swift in Sources */,
D703D78A2C670C8A00A400EA /* LibreTranslateServer.swift in Sources */, D703D78A2C670C8A00A400EA /* LibreTranslateServer.swift in Sources */,
D703D7602C670AAB00A400EA /* MigratedTypes.swift in Sources */, D703D7602C670AAB00A400EA /* MigratedTypes.swift in Sources */,
D73E5F742C6A9890007EB227 /* damusApp.swift in Sources */, D73E5F742C6A9890007EB227 /* damusApp.swift in Sources */,
@@ -74,7 +74,7 @@ struct DamusLabsExperiments: View {
LabsExplainerView( LabsExplainerView(
labName: gifs_label, labName: gifs_label,
systemImage: "", 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"))
} }
} }
} }
@@ -343,14 +343,6 @@ 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, // 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. // but the translation view needs non-Optional String in order to use them as Bindings.
@@ -366,8 +358,6 @@ class UserSettingsStore: ObservableObject {
@KeychainStorage(account: "libretranslate_apikey") @KeychainStorage(account: "libretranslate_apikey")
var internal_libretranslate_api_key: String? var internal_libretranslate_api_key: String?
@KeychainStorage(account: "tenor_api_key")
var internal_tenor_api_key: String?
@KeychainStorage(account: "nostr_wallet_connect") @KeychainStorage(account: "nostr_wallet_connect")
var nostr_wallet_connect: String? // TODO: strongly type this to WalletConnectURL var nostr_wallet_connect: String? // TODO: strongly type this to WalletConnectURL
@@ -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 // MARK: - Content filters and moderation
Section( Section(
-1
View File
@@ -10,5 +10,4 @@
import Foundation import Foundation
enum Secrets { enum Secrets {
static let TENOR_API_KEY: String? = ProcessInfo.processInfo.environment["TENOR_API_KEY"]
} }
+290
View File
@@ -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<CodingKeys>) 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<CodingKeys>) 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<CodingKeys>) 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
)
}
}
+153 -38
View File
@@ -13,10 +13,17 @@ struct GIFPickerView: View {
let damus_state: DamusState let damus_state: DamusState
let onGIFSelected: (URL) -> Void let onGIFSelected: (URL) -> Void
@StateObject private var viewModel = GIFPickerViewModel() @StateObject private var viewModel: GIFPickerViewModel
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var isSearchFocused: Bool @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 { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -59,7 +66,7 @@ struct GIFPickerView: View {
HStack { HStack {
Image("search") Image("search")
.foregroundColor(.gray) .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) .autocorrectionDisabled(true)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.focused($isSearchFocused) .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 { VStack {
Spacer() ErrorView(damus_state: damus_state, error: error)
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.secondary)
Text(error)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding()
Button(NSLocalizedString("Try Again", comment: "Button to retry loading GIFs")) { Button(NSLocalizedString("Try Again", comment: "Button to retry loading GIFs")) {
Task { Task {
if searchText.isEmpty { if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
await viewModel.loadFeatured() await viewModel.loadFeatured()
} else { return
}
viewModel.search(query: searchText) viewModel.search(query: searchText)
} }
} }
}
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() .padding(.bottom, 20)
} }
} }
@@ -158,7 +160,7 @@ struct GIFPickerView: View {
} }
struct GIFThumbnailView: View { struct GIFThumbnailView: View {
let gif: TenorGIFResult let gif: GIFResult
let disable_animation: Bool let disable_animation: Bool
var body: some View { var body: some View {
@@ -187,37 +189,55 @@ struct GIFThumbnailView: View {
@MainActor @MainActor
class GIFPickerViewModel: ObservableObject { class GIFPickerViewModel: ObservableObject {
@Published var gifs: [TenorGIFResult] = [] @Published var gifs: [GIFResult] = []
@Published var isLoading: Bool = false @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 currentQuery: String?
private var pendingQuery: String?
private var nextPos: String? private var nextPos: String?
private var currentPage = 1
private var hasMoreSearchResults = false
private var searchTask: Task<Void, Never>? private var searchTask: Task<Void, Never>?
/// 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 { func loadFeatured() async {
guard !isLoading else { return } guard !isLoading else { return }
isLoading = true isLoading = true
error = nil error = nil
currentQuery = nil currentQuery = nil
currentPage = 1
hasMoreSearchResults = false
do { do {
let response = try await api.fetchFeatured() let response = try await api.fetchFeatured(limit: featuredPageSize)
gifs = response.results gifs = response.results
nextPos = response.next nextPos = response.next
} catch { } catch {
self.error = error.localizedDescription self.error = makePresentableError(from: error, action: "loading featured GIFs")
} }
isLoading = false isLoading = false
await runPendingQueryIfNeeded()
} }
/// Starts a debounced GIF search.
func search(query: String) { func search(query: String) {
searchTask?.cancel() searchTask?.cancel()
guard !query.isEmpty else { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
pendingQuery = trimmedQuery.isEmpty ? nil : trimmedQuery
guard !trimmedQuery.isEmpty else {
Task { await loadFeatured() } Task { await loadFeatured() }
return return
} }
@@ -225,49 +245,146 @@ class GIFPickerViewModel: ObservableObject {
searchTask = Task { searchTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000) try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await performSearch(query: query) await performSearch(query: trimmedQuery)
} }
} }
/// Performs a GIF search request.
private func performSearch(query: String) async { private func performSearch(query: String) async {
guard !isLoading else { return } guard !isLoading else {
pendingQuery = query
return
}
isLoading = true isLoading = true
error = nil error = nil
currentQuery = query currentQuery = query
pendingQuery = nil
currentPage = 1
nextPos = nil nextPos = nil
hasMoreSearchResults = false
do { do {
let response = try await api.search(query: query) let response = try await api.search(query: query, page: currentPage, perPage: searchPageSize)
gifs = response.results gifs = response.results
nextPos = response.next hasMoreSearchResults = response.results.count >= searchPageSize
} catch { } catch {
self.error = error.localizedDescription self.error = makePresentableError(from: error, action: "searching GIFs")
} }
isLoading = false 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 { func loadMore() async {
guard !isLoading, let nextPos else { return } guard !isLoading else { return }
if let query = currentQuery {
guard hasMoreSearchResults else { return }
isLoading = true isLoading = true
do { do {
let response: TenorSearchResponse let nextPage = currentPage + 1
if let query = currentQuery { let response = try await api.search(query: query, page: nextPage, perPage: searchPageSize)
response = try await api.search(query: query, pos: nextPos)
} else {
response = try await api.fetchFeatured(pos: nextPos)
}
gifs.append(contentsOf: response.results) gifs.append(contentsOf: response.results)
self.nextPos = response.next currentPage = nextPage
hasMoreSearchResults = response.results.count >= searchPageSize
} catch { } catch {
// Don't show error for pagination failures
print("Failed to load more GIFs: \(error)") print("Failed to load more GIFs: \(error)")
} }
isLoading = false isLoading = false
return
}
guard let nextPos else { return }
isLoading = true
do {
let response = try await api.fetchFeatured(limit: featuredPageSize, pos: nextPos)
gifs.append(contentsOf: response.results)
self.nextPos = response.next
} catch {
print("Failed to load more GIFs: \(error)")
}
isLoading = false
}
}
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 ?? "<unavailable>"
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")"
)
}
} }
} }
@@ -276,5 +393,3 @@ class GIFPickerViewModel: ObservableObject {
print("Selected GIF: \(url)") print("Selected GIF: \(url)")
} }
} }
@@ -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
}
}
-112
View File
@@ -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)
}
}
}
-53
View File
@@ -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
}
}