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
2026-02-20 17:56:04 -08:00
committed by Daniel D’Aquino
parent f440f37cbf
commit 84ef5ecf53
9 changed files with 554 additions and 0 deletions
@@ -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
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"]
}
+280
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)")
}
}
+112
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)
}
}
}
+53
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
}
}