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