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:
ericholguin
2025-12-18 20:18:01 -07:00
committed by Daniel D’Aquino
parent f440f37cbf
commit 84ef5ecf53
9 changed files with 554 additions and 0 deletions

View File

@@ -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 */,

View File

@@ -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"))
}
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
View 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"]
}

View 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)")
}
}

View 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)
}
}
}

View 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
}
}