gifs: Tenor GIFs
This PR adds GIFs to Damus using Tenor as the service. This is a Damus Labs feature to begin with. In the future we should be able to also query nostr for gif media. Changelog-Added: Added GIF keyboard support (Damus Labs only) Signed-off-by: ericholguin <ericholguin@apache.org>
This commit is contained in:
committed by
Daniel D’Aquino
parent
f440f37cbf
commit
84ef5ecf53
@@ -626,6 +626,18 @@
|
||||
5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; };
|
||||
5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; };
|
||||
5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF72FC129B9142F00124A13 /* ShareAction.swift */; };
|
||||
5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; };
|
||||
5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; };
|
||||
5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */; };
|
||||
5CFDE6ED2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
|
||||
5CFDE6EE2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
|
||||
5CFDE6EF2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */; };
|
||||
5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; };
|
||||
5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; };
|
||||
5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */; };
|
||||
5CFDE6F52EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; };
|
||||
5CFDE6F62EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; };
|
||||
5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFDE6F42EF4F939004E8661 /* Secrets.swift */; };
|
||||
6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6439E013296790CF0020672B /* ProfilePicImageView.swift */; };
|
||||
643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643EA5C7296B764E005081BB /* RelayFilterView.swift */; };
|
||||
647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647D9A8C2968520300A295DE /* SideMenuView.swift */; };
|
||||
@@ -2708,6 +2720,10 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = "<group>"; };
|
||||
647D9A8C2968520300A295DE /* SideMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuView.swift; sourceTree = "<group>"; };
|
||||
@@ -3845,6 +3861,7 @@
|
||||
4CE6DEE527F7A08100C66700 /* damus */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CFDE6F42EF4F939004E8661 /* Secrets.swift */,
|
||||
5C78A7932E30387400CF177D /* Shared */,
|
||||
5C78A7792E22FDFE00CF177D /* Features */,
|
||||
5C78A7752E22F84A00CF177D /* Core */,
|
||||
@@ -4613,6 +4630,7 @@
|
||||
5C78A79C2E303CA300CF177D /* Media */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CFDE6E32EF4F773004E8661 /* GIF */,
|
||||
5C78A79D2E303D2600CF177D /* Models */,
|
||||
4CFF8F6129CC9A80008DB934 /* Images */,
|
||||
4C1A9A2829DDF53B00516EAC /* Video */,
|
||||
@@ -5153,6 +5171,16 @@
|
||||
path = Detail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CFDE6E32EF4F773004E8661 /* GIF */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */,
|
||||
5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */,
|
||||
5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */,
|
||||
);
|
||||
path = GIF;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7C0F392D29B57C8F0039859C /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -5891,12 +5919,14 @@
|
||||
4C3D52B6298DB4E6001C5831 /* ZapEvent.swift in Sources */,
|
||||
4CF4804D2B631C0100F2B2C0 /* amount.c in Sources */,
|
||||
647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */,
|
||||
5CFDE6EF2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */,
|
||||
D7AAD0012E0387B800FB7699 /* LnurlAmountView.swift in Sources */,
|
||||
F7F0BA272978E54D009531F3 /* ParticipantsView.swift in Sources */,
|
||||
4CF0ABE32981BC7D00D66079 /* UserView.swift in Sources */,
|
||||
4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */,
|
||||
B51C1CEA2B55A60A00E312A9 /* AddMuteItemView.swift in Sources */,
|
||||
5C8F97332EB46126009399B1 /* LiveStreamViewers.swift in Sources */,
|
||||
5CFDE6F22EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */,
|
||||
4C5D5C992A6AF8F80024563C /* NdbNote.swift in Sources */,
|
||||
4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */,
|
||||
D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */,
|
||||
@@ -6120,6 +6150,7 @@
|
||||
50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */,
|
||||
5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */,
|
||||
4C1253542A76C7D60004F4B8 /* LogoutNotify.swift in Sources */,
|
||||
5CFDE6F52EF4F93D004E8661 /* Secrets.swift in Sources */,
|
||||
5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */,
|
||||
4CC14FF52A740BB7007AEB17 /* NoteId.swift in Sources */,
|
||||
4C19AE512A5CEF7C00C90DB7 /* NostrScript.swift in Sources */,
|
||||
@@ -6298,6 +6329,7 @@
|
||||
4C1A9A2129DDD3E100516EAC /* KeySettingsView.swift in Sources */,
|
||||
D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */,
|
||||
4C011B5E2BD0A56A002F2F9B /* ChatEventView.swift in Sources */,
|
||||
5CFDE6E62EF4F782004E8661 /* TenorModels.swift in Sources */,
|
||||
4C32B95A2A9AD44700DC3548 /* Verifiable.swift in Sources */,
|
||||
4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */,
|
||||
501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */,
|
||||
@@ -6502,6 +6534,7 @@
|
||||
3ACF94482DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */,
|
||||
82D6FAE92CD99F7900C925F4 /* NewMutesNotify.swift in Sources */,
|
||||
82D6FAEA2CD99F7900C925F4 /* NewUnmutesNotify.swift in Sources */,
|
||||
5CFDE6F12EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */,
|
||||
82D6FAEB2CD99F7900C925F4 /* Notify.swift in Sources */,
|
||||
82D6FAEC2CD99F7900C925F4 /* OnlyZapsNotify.swift in Sources */,
|
||||
82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */,
|
||||
@@ -6604,6 +6637,7 @@
|
||||
82D6FB3C2CD99F7900C925F4 /* AnyEncodable.swift in Sources */,
|
||||
82D6FB3D2CD99F7900C925F4 /* Zap.swift in Sources */,
|
||||
82D6FB3E2CD99F7900C925F4 /* NIPURLBuilder.swift in Sources */,
|
||||
5CFDE6ED2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */,
|
||||
82D6FB3F2CD99F7900C925F4 /* TimeAgo.swift in Sources */,
|
||||
82D6FB402CD99F7900C925F4 /* Parser.swift in Sources */,
|
||||
82D6FB412CD99F7900C925F4 /* InsertSort.swift in Sources */,
|
||||
@@ -6819,6 +6853,7 @@
|
||||
82D6FC002CD99F7900C925F4 /* ProfilePicturesView.swift in Sources */,
|
||||
82D6FC012CD99F7900C925F4 /* DamusAppNotificationView.swift in Sources */,
|
||||
3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */,
|
||||
5CFDE6E52EF4F782004E8661 /* TenorModels.swift in Sources */,
|
||||
82D6FC022CD99F7900C925F4 /* InnerTimelineView.swift in Sources */,
|
||||
82D6FC032CD99F7900C925F4 /* PostingTimelineView.swift in Sources */,
|
||||
82D6FC042CD99F7900C925F4 /* ZapsView.swift in Sources */,
|
||||
@@ -6886,6 +6921,7 @@
|
||||
82D6FC362CD99F7900C925F4 /* EventMenu.swift in Sources */,
|
||||
D7AAD0002E0387B800FB7699 /* LnurlAmountView.swift in Sources */,
|
||||
82D6FC372CD99F7900C925F4 /* EventMutingContainerView.swift in Sources */,
|
||||
5CFDE6F62EF4F93D004E8661 /* Secrets.swift in Sources */,
|
||||
82D6FC382CD99F7900C925F4 /* ZapEvent.swift in Sources */,
|
||||
82D6FC392CD99F7900C925F4 /* TextEvent.swift in Sources */,
|
||||
82D6FC3A2CD99F7900C925F4 /* WideEventView.swift in Sources */,
|
||||
@@ -7008,6 +7044,7 @@
|
||||
D73E5E2B2C6A97F4007EB227 /* PostNotify.swift in Sources */,
|
||||
D73E5E2C2C6A97F4007EB227 /* PresentSheetNotify.swift in Sources */,
|
||||
D73E5E2D2C6A97F4007EB227 /* ProfileUpdatedNotify.swift in Sources */,
|
||||
5CFDE6F32EF4F7EA004E8661 /* TenorAPIClient.swift in Sources */,
|
||||
3A515C522DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */,
|
||||
D73E5E2E2C6A97F4007EB227 /* ReportNotify.swift in Sources */,
|
||||
D73E5E2F2C6A97F4007EB227 /* ScrollToTopNotify.swift in Sources */,
|
||||
@@ -7051,6 +7088,7 @@
|
||||
D77DA2CA2F19D480000B7093 /* NegentropyUtilities.swift in Sources */,
|
||||
D73E5E552C6A97F4007EB227 /* TranslateView.swift in Sources */,
|
||||
D73E5E562C6A97F4007EB227 /* SelectableText.swift in Sources */,
|
||||
5CFDE6EE2EF4F7B2004E8661 /* GIFPickerView.swift in Sources */,
|
||||
D73E5E572C6A97F4007EB227 /* DamusColors.swift in Sources */,
|
||||
D73E5E582C6A97F4007EB227 /* ThiccDivider.swift in Sources */,
|
||||
D73E5E592C6A97F4007EB227 /* IconLabel.swift in Sources */,
|
||||
@@ -7401,6 +7439,7 @@
|
||||
D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */,
|
||||
D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */,
|
||||
D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */,
|
||||
5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */,
|
||||
D703D78A2C670C8A00A400EA /* LibreTranslateServer.swift in Sources */,
|
||||
D703D7602C670AAB00A400EA /* MigratedTypes.swift in Sources */,
|
||||
D73E5F742C6A9890007EB227 /* damusApp.swift in Sources */,
|
||||
@@ -7438,6 +7477,7 @@
|
||||
D73E5E1B2C6A9672007EB227 /* LikeCounter.swift in Sources */,
|
||||
D703D7A92C670E5A00A400EA /* refmap.c in Sources */,
|
||||
D73C7EDC2DE51699001F9392 /* OnboardingContentSettings.swift in Sources */,
|
||||
5CFDE6F72EF4F93D004E8661 /* Secrets.swift in Sources */,
|
||||
D703D77B2C670BF000A400EA /* TableVerifier.swift in Sources */,
|
||||
3ACF94442DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */,
|
||||
D703D76D2C670B4500A400EA /* ZapDataModel.swift in Sources */,
|
||||
|
||||
@@ -13,9 +13,11 @@ struct DamusLabsExperiments: View {
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@State var show_live_explainer: Bool = false
|
||||
@State var show_favorites_explainer: Bool = false
|
||||
@State var show_gifs_explainer: Bool = false
|
||||
|
||||
let live_label = NSLocalizedString("Live", comment: "Label for a toggle that enables an experimental feature")
|
||||
let favorites_label = NSLocalizedString("Favorites", comment: "Label for a toggle that enables an experimental feature")
|
||||
let gifs_label = NSLocalizedString("GIFs", comment: "Label for a toggle that enables an experimental feature")
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -44,6 +46,7 @@ struct DamusLabsExperiments: View {
|
||||
|
||||
LabsToggleView(toggleName: live_label, systemImage: "record.circle", isOn: $settings.live, showInfo: $show_live_explainer)
|
||||
LabsToggleView(toggleName: favorites_label, systemImage: "heart.fill", isOn: $settings.enable_favourites_feature, showInfo: $show_favorites_explainer)
|
||||
LabsToggleView(toggleName: gifs_label, systemImage: "smiley", isOn: $settings.enable_gifs_feature, showInfo: $show_gifs_explainer)
|
||||
|
||||
}
|
||||
.padding([.trailing, .leading], 20)
|
||||
@@ -67,6 +70,12 @@ struct DamusLabsExperiments: View {
|
||||
systemImage: "heart.fill",
|
||||
labDescription: NSLocalizedString("This will allow you to pick users to be part of your favorites list. You can also switch your profile timeline to only see posts from your favorite contacts.", comment: "Damus Labs feature explanation"))
|
||||
}
|
||||
.sheet(isPresented: $show_gifs_explainer) {
|
||||
LabsExplainerView(
|
||||
labName: gifs_label,
|
||||
systemImage: "",
|
||||
labDescription: NSLocalizedString("This will allow you to easily add gifs from Tenor to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show you all of tenor's featured GIFs. You can also search for GIFs.", comment: "Damus Labs feature explanation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ struct PostView: View {
|
||||
@FocusState var focus: Bool
|
||||
@State var attach_media: Bool = false
|
||||
@State var attach_camera: Bool = false
|
||||
@State var attach_gif: Bool = false
|
||||
@State var error: String? = nil
|
||||
@State var image_upload_confirm: Bool = false
|
||||
@State var imagePastedFromPasteboard: PreUploadedMedia? = nil
|
||||
@@ -277,10 +278,22 @@ struct PostView: View {
|
||||
})
|
||||
}
|
||||
|
||||
var GIFButton: some View {
|
||||
Button(action: {
|
||||
attach_gif = true
|
||||
}, label: {
|
||||
Image("GIF")
|
||||
.padding(6)
|
||||
})
|
||||
}
|
||||
|
||||
var AttachmentBar: some View {
|
||||
HStack(alignment: .center, spacing: 15) {
|
||||
ImageButton
|
||||
CameraButton
|
||||
if damus_state.settings.enable_gifs_feature {
|
||||
GIFButton
|
||||
}
|
||||
Spacer()
|
||||
AutoSaveIndicatorView(saveViewModel: self.autoSaveModel)
|
||||
}
|
||||
@@ -623,6 +636,14 @@ struct PostView: View {
|
||||
self.attach_media = true
|
||||
}))
|
||||
}
|
||||
.sheet(isPresented: $attach_gif) {
|
||||
GIFPickerView(damus_state: damus_state) { gifURL in
|
||||
let uploadedMedia = UploadedMedia(localURL: gifURL, uploadedURL: gifURL, metadata: nil)
|
||||
uploadedMedias.append(uploadedMedia)
|
||||
post_changed(post: post, media: uploadedMedias)
|
||||
attach_gif = false
|
||||
}
|
||||
}
|
||||
// This alert seeks confirmation about Image-upload when user taps Paste option
|
||||
.alert(NSLocalizedString("Are you sure you want to upload this media?", comment: "Alert message asking if the user wants to upload media."), isPresented: $imageUploadConfirmPasteboard) {
|
||||
Button(NSLocalizedString("Upload", comment: "Button to proceed with uploading."), role: .none) {
|
||||
|
||||
@@ -339,6 +339,15 @@ class UserSettingsStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var tenor_api_key: String {
|
||||
get {
|
||||
return internal_tenor_api_key ?? ""
|
||||
}
|
||||
set {
|
||||
internal_tenor_api_key = newValue == "" ? nil : newValue
|
||||
}
|
||||
}
|
||||
|
||||
// These internal keys are necessary because entries in the keychain need to be Optional,
|
||||
// but the translation view needs non-Optional String in order to use them as Bindings.
|
||||
@KeychainStorage(account: "deepl_apikey")
|
||||
@@ -353,6 +362,9 @@ class UserSettingsStore: ObservableObject {
|
||||
@KeychainStorage(account: "libretranslate_apikey")
|
||||
var internal_libretranslate_api_key: String?
|
||||
|
||||
@KeychainStorage(account: "tenor_api_key")
|
||||
var internal_tenor_api_key: String?
|
||||
|
||||
@KeychainStorage(account: "nostr_wallet_connect")
|
||||
var nostr_wallet_connect: String? // TODO: strongly type this to WalletConnectURL
|
||||
|
||||
@@ -381,6 +393,10 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "labs_experiment_favorites", default_value: false)
|
||||
var enable_favourites_feature: Bool
|
||||
|
||||
/// Whether the app should show the GIF feature (Damus Labs)
|
||||
@Setting(key: "labs_experiment_gifs", default_value: false)
|
||||
var enable_gifs_feature: Bool
|
||||
|
||||
// MARK: Internal, hidden settings
|
||||
|
||||
// TODO: Get rid of this once we have NostrDB query capabilities integrated
|
||||
|
||||
@@ -146,6 +146,15 @@ struct AppearanceSettingsView: View {
|
||||
self.ClearCacheButton
|
||||
}
|
||||
|
||||
// MARK: - GIFs
|
||||
if damus_state.settings.enable_gifs_feature {
|
||||
Section(NSLocalizedString("GIFs", comment: "Section title for GIFs configuration.")) {
|
||||
SecureField(NSLocalizedString("Tenor API Key (optional)", comment: "Prompt for optional entry of API Key to use with Tenor."), text: $settings.tenor_api_key)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(UITextAutocapitalizationType.none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content filters and moderation
|
||||
Section(
|
||||
header: Text("Content filters", comment: "Section title for content filtering/moderation configuration."),
|
||||
|
||||
14
damus/Secrets.swift
Normal file
14
damus/Secrets.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Secrets.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 12/18/25.
|
||||
//
|
||||
// This file contains a list of secrets imported from environment variables,
|
||||
// where those environment variables cannot be committed to git for security reasons.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Secrets {
|
||||
static let TENOR_API_KEY: String? = ProcessInfo.processInfo.environment["TENOR_API_KEY"]
|
||||
}
|
||||
280
damus/Shared/Media/GIF/GIFPickerView.swift
Normal file
280
damus/Shared/Media/GIF/GIFPickerView.swift
Normal file
@@ -0,0 +1,280 @@
|
||||
//
|
||||
// GIFPickerView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 12/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct GIFPickerView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
let damus_state: DamusState
|
||||
let onGIFSelected: (URL) -> Void
|
||||
|
||||
@StateObject private var viewModel = GIFPickerViewModel()
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
SearchInput
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.isLoading && viewModel.gifs.isEmpty {
|
||||
loadingView
|
||||
} else if let error = viewModel.error {
|
||||
errorView(error)
|
||||
} else if viewModel.gifs.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
gifGrid
|
||||
}
|
||||
}
|
||||
.navigationTitle(NSLocalizedString("Select GIF", comment: "Title for GIF picker sheet"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel GIF selection")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadFeatured()
|
||||
}
|
||||
.onChange(of: searchText) { newValue in
|
||||
viewModel.search(query: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var SearchInput: some View {
|
||||
HStack {
|
||||
HStack {
|
||||
Image("search")
|
||||
.foregroundColor(.gray)
|
||||
TextField(NSLocalizedString("Search GIFs...", comment: "Placeholder for GIF search field"), text: $searchText)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($isSearchFocused)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button(action: { searchText = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.secondary.opacity(0.2))
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
|
||||
private var gifGrid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible(), spacing: 4),
|
||||
GridItem(.flexible(), spacing: 4)
|
||||
], spacing: 4) {
|
||||
ForEach(viewModel.gifs) { gif in
|
||||
GIFThumbnailView(gif: gif, disable_animation: damus_state.settings.disable_animation)
|
||||
.onTapGesture {
|
||||
if let gifURL = gif.mediumURL ?? gif.fullURL {
|
||||
onGIFSelected(gifURL)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if gif.id == viewModel.gifs.last?.id {
|
||||
Task { await viewModel.loadMore() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
|
||||
if viewModel.isLoading && !viewModel.gifs.isEmpty {
|
||||
ProgressView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
Text("Loading GIFs...", comment: "Loading indicator text for GIF picker")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func errorView(_ error: String) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
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")) {
|
||||
Task {
|
||||
if searchText.isEmpty {
|
||||
await viewModel.loadFeatured()
|
||||
} else {
|
||||
viewModel.search(query: searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No GIFs found", comment: "Message when no GIFs match search")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GIFThumbnailView: View {
|
||||
let gif: TenorGIFResult
|
||||
let disable_animation: Bool
|
||||
|
||||
var body: some View {
|
||||
if let previewURL = gif.previewURL {
|
||||
KFAnimatedImage(previewURL)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.placeholder {
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
}
|
||||
.imageContext(.note, disable_animation: disable_animation)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.frame(height: 120)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class GIFPickerViewModel: ObservableObject {
|
||||
@Published var gifs: [TenorGIFResult] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var error: String? = nil
|
||||
|
||||
private let api = TenorAPIClient()
|
||||
private var currentQuery: String?
|
||||
private var nextPos: String?
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
func loadFeatured() async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
currentQuery = nil
|
||||
|
||||
do {
|
||||
let response = try await api.fetchFeatured()
|
||||
gifs = response.results
|
||||
nextPos = response.next
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func search(query: String) {
|
||||
searchTask?.cancel()
|
||||
|
||||
guard !query.isEmpty else {
|
||||
Task { await loadFeatured() }
|
||||
return
|
||||
}
|
||||
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await performSearch(query: query)
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch(query: String) async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
currentQuery = query
|
||||
nextPos = nil
|
||||
|
||||
do {
|
||||
let response = try await api.search(query: query)
|
||||
gifs = response.results
|
||||
nextPos = response.next
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadMore() async {
|
||||
guard !isLoading, let nextPos else { return }
|
||||
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
let response: TenorSearchResponse
|
||||
if let query = currentQuery {
|
||||
response = try await api.search(query: query, pos: nextPos)
|
||||
} else {
|
||||
response = try await api.fetchFeatured(pos: nextPos)
|
||||
}
|
||||
gifs.append(contentsOf: response.results)
|
||||
self.nextPos = response.next
|
||||
} catch {
|
||||
// Don't show error for pagination failures
|
||||
print("Failed to load more GIFs: \(error)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GIFPickerView(damus_state: test_damus_state) { url in
|
||||
print("Selected GIF: \(url)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
damus/Shared/Media/GIF/TenorAPIClient.swift
Normal file
112
damus/Shared/Media/GIF/TenorAPIClient.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// 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
damus/Shared/Media/GIF/TenorModels.swift
Normal file
53
damus/Shared/Media/GIF/TenorModels.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// 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