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:
@@ -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(
|
||||||
|
|||||||
@@ -10,5 +10,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Secrets {
|
enum Secrets {
|
||||||
static let TENOR_API_KEY: String? = ProcessInfo.processInfo.environment["TENOR_API_KEY"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,45 +245,95 @@ 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
|
||||||
|
|
||||||
|
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
|
isLoading = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response: TenorSearchResponse
|
let response = try await api.fetchFeatured(limit: featuredPageSize, pos: nextPos)
|
||||||
if let query = currentQuery {
|
|
||||||
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
|
self.nextPos = response.next
|
||||||
} catch {
|
} catch {
|
||||||
// Don't show error for pagination failures
|
|
||||||
print("Failed to load more GIFs: \(error)")
|
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 ?? "<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")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
GIFPickerView(damus_state: test_damus_state) { url in
|
GIFPickerView(damus_state: test_damus_state) { url in
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user