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