Replace Tenor GIF integration with Purple proxy

Remove Tenor API client and models; add PurpleGIFAPIClient and a
robust GIFModels decoder that supports Tenor and KLIPY payloads.
Remove Tenor API key setting and Secrets entry. Update GIF picker to
accept a Purple-backed view model, adjust Labs text, and update
Xcode project file references.

Closes: #3695
Changelog-Changed: Made GIF keyboard widely available to Purple users
without need for an API key (Damus Labs)

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-05-04 11:39:15 -07:00
parent 9dc9738377
commit 111fabf0b3
10 changed files with 596 additions and 240 deletions
@@ -74,7 +74,7 @@ struct DamusLabsExperiments: View {
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"))
labDescription: NSLocalizedString("This will allow you to easily add GIFs to your posts. You will see the GIF icon in the attachment bar when creating a post. Tapping it will show featured GIFs for Purple subscribers, and you can also search for GIFs.", comment: "Damus Labs feature explanation"))
}
}
}
@@ -343,15 +343,7 @@ 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")
@@ -366,9 +358,7 @@ 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
@@ -132,14 +132,6 @@ struct AppearanceSettingsView: View {
}
}
// 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(
-1
View File
@@ -10,5 +10,4 @@
import Foundation
enum Secrets {
static let TENOR_API_KEY: String? = ProcessInfo.processInfo.environment["TENOR_API_KEY"]
}
+290
View File
@@ -0,0 +1,290 @@
//
// GIFModels.swift
// damus
//
import Foundation
struct GIFSearchResponse: Decodable {
let results: [GIFResult]
let next: String?
/// Decodes GIF payloads from both featured and search endpoints.
///
/// Featured returns a Tenor-style payload with top-level `results` and `next`.
/// Search returns a KLIPY payload with a nested `data.data` array plus paging metadata.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let results = try container.decodeIfPresent([GIFResult].self, forKey: .results) {
self.results = results
self.next = try container.decodeIfPresent(String.self, forKey: .next)
return
}
if let searchPayload = try container.decodeIfPresent(KLIPYSearchPayload.self, forKey: .data) {
self.results = searchPayload.data
self.next = searchPayload.has_next ? String(searchPayload.current_page + 1) : nil
return
}
throw DecodingError.keyNotFound(
CodingKeys.results,
.init(
codingPath: decoder.codingPath,
debugDescription: "No supported GIF results payload was found. Expected either top-level results or nested data.data."
)
)
}
private enum CodingKeys: String, CodingKey {
case results
case next
case data
}
private struct KLIPYSearchPayload: Decodable {
let data: [GIFResult]
let current_page: Int
let per_page: Int
let has_next: Bool
}
}
struct GIFResult: Decodable, Identifiable {
let id: String
let title: String?
let media_formats: GIFMediaFormats?
let content_description: String?
let slug: String?
/// Decodes either featured-style or KLIPY search-style GIF payloads.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try GIFResult.decodeID(from: container)
self.title = try container.decodeIfPresent(String.self, forKey: .title)
self.media_formats = try GIFResult.decodeMediaFormats(from: container)
self.content_description = try container.decodeIfPresent(String.self, forKey: .content_description)
self.slug = try container.decodeIfPresent(String.self, forKey: .slug)
}
var previewURL: URL? {
guard let url = media_formats?.preview?.url else { return nil }
return URL(string: url)
}
var fullURL: URL? {
guard let url = media_formats?.primary?.url else { return nil }
return URL(string: url)
}
var mediumURL: URL? {
guard let url = media_formats?.medium?.url else { return nil }
return URL(string: url)
}
private enum CodingKeys: String, CodingKey {
case id
case title
case media_formats
case content_description
case slug
case file
}
/// Decodes GIF IDs that may arrive as strings or numbers.
private static func decodeID(from container: KeyedDecodingContainer<CodingKeys>) throws -> String {
if let stringID = try? container.decode(String.self, forKey: .id) {
return stringID
}
if let intID = try? container.decode(Int.self, forKey: .id) {
return String(intID)
}
if let int64ID = try? container.decode(Int64.self, forKey: .id) {
return String(int64ID)
}
if let uint64ID = try? container.decode(UInt64.self, forKey: .id) {
return String(uint64ID)
}
if let doubleID = try? container.decode(Double.self, forKey: .id) {
guard doubleID.isFinite, !doubleID.isNaN else {
throw DecodingError.dataCorruptedError(
forKey: .id,
in: container,
debugDescription: "GIF id must be a finite number."
)
}
guard doubleID.rounded() == doubleID else {
throw DecodingError.dataCorruptedError(
forKey: .id,
in: container,
debugDescription: "GIF id numeric value must be integral."
)
}
if let exactInt64 = Int64(exactly: doubleID) {
return String(exactInt64)
}
if let exactUInt64 = UInt64(exactly: doubleID) {
return String(exactUInt64)
}
throw DecodingError.dataCorruptedError(
forKey: .id,
in: container,
debugDescription: "GIF id numeric value is out of supported range."
)
}
throw DecodingError.dataCorruptedError(
forKey: .id,
in: container,
debugDescription: "No supported GIF id value was found."
)
}
/// Decodes either shared media formats or KLIPY's nested file formats.
private static func decodeMediaFormats(from container: KeyedDecodingContainer<CodingKeys>) throws -> GIFMediaFormats? {
if let mediaFormats = try container.decodeIfPresent(GIFMediaFormats.self, forKey: .media_formats) {
return mediaFormats
}
if let klipyFile = try container.decodeIfPresent(KLIPYFileFormats.self, forKey: .file) {
return klipyFile.asMediaFormats
}
return nil
}
}
struct GIFMediaFormats: Decodable {
let gif: GIFMediaFormat?
let webp: GIFMediaFormat?
let jpg: GIFMediaFormat?
let mp4: GIFMediaFormat?
let webm: GIFMediaFormat?
let tinygif: GIFMediaFormat?
let tinymp4: GIFMediaFormat?
let tinywebm: GIFMediaFormat?
var preview: GIFMediaFormat? {
tinygif ?? tinymp4 ?? tinywebm ?? gif ?? webp ?? jpg ?? mp4 ?? webm
}
var medium: GIFMediaFormat? {
gif ?? webp ?? mp4 ?? webm ?? jpg
}
var primary: GIFMediaFormat? {
gif ?? webp ?? mp4 ?? webm ?? jpg
}
}
struct GIFMediaFormat: Decodable {
let url: String
let dims: [Int]?
let duration: Double?
let size: Int?
/// Decodes either shared Damus media format fields or KLIPY width/height fields.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.url = try container.decode(String.self, forKey: .url)
self.duration = try container.decodeIfPresent(Double.self, forKey: .duration)
self.size = try container.decodeIfPresent(Int.self, forKey: .size)
if let dims = try container.decodeIfPresent([Int].self, forKey: .dims) {
self.dims = dims
return
}
let width = try GIFMediaFormat.decodeIntegerValue(forKey: .width, from: container)
let height = try GIFMediaFormat.decodeIntegerValue(forKey: .height, from: container)
if let width, let height {
self.dims = [width, height]
return
}
self.dims = nil
}
var width: Int? {
dims?.count ?? 0 >= 1 ? dims?[0] : nil
}
var height: Int? {
dims?.count ?? 0 >= 2 ? dims?[1] : nil
}
private enum CodingKeys: String, CodingKey {
case url
case dims
case duration
case size
case width
case height
}
/// Decodes integer-like numeric fields that may be emitted as either integers or doubles.
private static func decodeIntegerValue(forKey key: CodingKeys, from container: KeyedDecodingContainer<CodingKeys>) throws -> Int? {
if let intValue = try? container.decode(Int.self, forKey: key) {
return intValue
}
guard let doubleValue = try? container.decode(Double.self, forKey: key) else {
return nil
}
guard doubleValue.isFinite, !doubleValue.isNaN else {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "GIF numeric field must be a finite number."
)
}
guard doubleValue.rounded() == doubleValue else {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "GIF numeric field must be an integral value."
)
}
guard let exactInt = Int(exactly: doubleValue) else {
throw DecodingError.dataCorruptedError(
forKey: key,
in: container,
debugDescription: "GIF numeric field is out of supported range."
)
}
return exactInt
}
}
private struct KLIPYFileFormats: Decodable {
let hd: GIFMediaFormats?
let md: GIFMediaFormats?
let sm: GIFMediaFormats?
let xs: GIFMediaFormats?
var asMediaFormats: GIFMediaFormats {
sm ?? md ?? hd ?? xs ?? GIFMediaFormats(
gif: nil,
webp: nil,
jpg: nil,
mp4: nil,
webm: nil,
tinygif: nil,
tinymp4: nil,
tinywebm: nil
)
}
}
+152 -37
View File
@@ -13,10 +13,17 @@ struct GIFPickerView: View {
let damus_state: DamusState
let onGIFSelected: (URL) -> Void
@StateObject private var viewModel = GIFPickerViewModel()
@StateObject private var viewModel: GIFPickerViewModel
@State private var searchText: String = ""
@FocusState private var isSearchFocused: Bool
/// Creates a GIF picker bound to the current Damus state.
init(damus_state: DamusState, onGIFSelected: @escaping (URL) -> Void) {
self.damus_state = damus_state
self.onGIFSelected = onGIFSelected
_viewModel = StateObject(wrappedValue: GIFPickerViewModel(purple: damus_state.purple))
}
var body: some View {
NavigationView {
VStack(spacing: 0) {
@@ -59,7 +66,7 @@ struct GIFPickerView: View {
HStack {
Image("search")
.foregroundColor(.gray)
TextField(NSLocalizedString("Search GIFs...", comment: "Placeholder for GIF search field"), text: $searchText)
TextField(NSLocalizedString("Search KLIPY", comment: "Placeholder for GIF search field"), text: $searchText)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.focused($isSearchFocused)
@@ -119,27 +126,22 @@ struct GIFPickerView: View {
}
}
private func errorView(_ error: String) -> some View {
private func errorView(_ error: ErrorView.UserPresentableError) -> some View {
VStack {
Spacer()
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.secondary)
Text(error)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding()
ErrorView(damus_state: damus_state, error: error)
Button(NSLocalizedString("Try Again", comment: "Button to retry loading GIFs")) {
Task {
if searchText.isEmpty {
if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
await viewModel.loadFeatured()
} else {
viewModel.search(query: searchText)
return
}
viewModel.search(query: searchText)
}
}
.buttonStyle(.bordered)
Spacer()
.padding(.bottom, 20)
}
}
@@ -158,7 +160,7 @@ struct GIFPickerView: View {
}
struct GIFThumbnailView: View {
let gif: TenorGIFResult
let gif: GIFResult
let disable_animation: Bool
var body: some View {
@@ -187,37 +189,55 @@ struct GIFThumbnailView: View {
@MainActor
class GIFPickerViewModel: ObservableObject {
@Published var gifs: [TenorGIFResult] = []
@Published var gifs: [GIFResult] = []
@Published var isLoading: Bool = false
@Published var error: String? = nil
@Published var error: ErrorView.UserPresentableError? = nil
private let api = TenorAPIClient()
private let api: PurpleGIFAPIClient
private let featuredPageSize = 30
private let searchPageSize = 30
private var currentQuery: String?
private var pendingQuery: String?
private var nextPos: String?
private var currentPage = 1
private var hasMoreSearchResults = false
private var searchTask: Task<Void, Never>?
/// Initializes a GIF picker view model backed by Purple.
init(purple: DamusPurple) {
self.api = PurpleGIFAPIClient(purple: purple)
}
/// Loads featured GIFs for the initial picker state.
func loadFeatured() async {
guard !isLoading else { return }
isLoading = true
error = nil
currentQuery = nil
currentPage = 1
hasMoreSearchResults = false
do {
let response = try await api.fetchFeatured()
let response = try await api.fetchFeatured(limit: featuredPageSize)
gifs = response.results
nextPos = response.next
} catch {
self.error = error.localizedDescription
self.error = makePresentableError(from: error, action: "loading featured GIFs")
}
isLoading = false
await runPendingQueryIfNeeded()
}
/// Starts a debounced GIF search.
func search(query: String) {
searchTask?.cancel()
guard !query.isEmpty else {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
pendingQuery = trimmedQuery.isEmpty ? nil : trimmedQuery
guard !trimmedQuery.isEmpty else {
Task { await loadFeatured() }
return
}
@@ -225,45 +245,95 @@ class GIFPickerViewModel: ObservableObject {
searchTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
await performSearch(query: query)
await performSearch(query: trimmedQuery)
}
}
/// Performs a GIF search request.
private func performSearch(query: String) async {
guard !isLoading else { return }
guard !isLoading else {
pendingQuery = query
return
}
isLoading = true
error = nil
currentQuery = query
pendingQuery = nil
currentPage = 1
nextPos = nil
hasMoreSearchResults = false
do {
let response = try await api.search(query: query)
let response = try await api.search(query: query, page: currentPage, perPage: searchPageSize)
gifs = response.results
nextPos = response.next
hasMoreSearchResults = response.results.count >= searchPageSize
} catch {
self.error = error.localizedDescription
self.error = makePresentableError(from: error, action: "searching GIFs")
}
isLoading = false
await runPendingQueryIfNeeded()
}
/// Converts technical GIF loading errors into user-presentable content.
private func makePresentableError(from error: Error, action: String) -> ErrorView.UserPresentableError {
if let presentableError = error as? ErrorView.UserPresentableErrorProtocol {
return presentableError.userPresentableError
}
if let gifError = error as? PurpleGIFAPIError {
return gifError.userPresentableError(action: action, currentQuery: currentQuery)
}
return .init(
user_visible_description: NSLocalizedString("We couldn't load GIFs right now.", comment: "Fallback error shown when the GIF picker fails unexpectedly."),
tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Fallback advice shown when the GIF picker fails unexpectedly."),
technical_info: "GIF picker error while \(action): \(String(describing: error))"
)
}
/// Runs the latest queued search after the current load completes.
private func runPendingQueryIfNeeded() async {
guard !isLoading else { return }
guard let pendingQuery, pendingQuery != currentQuery else { return }
self.pendingQuery = nil
await performSearch(query: pendingQuery)
}
/// Loads the next page of GIF results.
func loadMore() async {
guard !isLoading, let nextPos else { return }
guard !isLoading else { return }
if let query = currentQuery {
guard hasMoreSearchResults else { return }
isLoading = true
do {
let nextPage = currentPage + 1
let response = try await api.search(query: query, page: nextPage, perPage: searchPageSize)
gifs.append(contentsOf: response.results)
currentPage = nextPage
hasMoreSearchResults = response.results.count >= searchPageSize
} catch {
print("Failed to load more GIFs: \(error)")
}
isLoading = false
return
}
guard 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)
}
let response = try await api.fetchFeatured(limit: featuredPageSize, 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)")
}
@@ -271,10 +341,55 @@ class GIFPickerViewModel: ObservableObject {
}
}
private extension PurpleGIFAPIError {
/// Converts Purple GIF API failures into a reusable user-presentable error.
func userPresentableError(action: String, currentQuery: String?) -> ErrorView.UserPresentableError {
let queryContext = currentQuery.map { "query=\($0)" } ?? "featured"
switch self {
case .unauthorized:
return .init(
user_visible_description: NSLocalizedString("You need an active Purple subscription to use GIF search.", comment: "Error shown when the user is not authorized to use the GIF picker."),
tip: NSLocalizedString("Make sure you're signed in with the right account and that your Purple subscription is active, then try again.", comment: "Advice shown when GIF picker access is denied."),
technical_info: "GIF picker unauthorized while \(action); context=\(queryContext)"
)
case .invalidURL:
return .init(
user_visible_description: NSLocalizedString("The GIF service is misconfigured.", comment: "Error shown when the GIF picker generated an invalid URL."),
tip: NSLocalizedString("Try again later. If this keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker URL is invalid."),
technical_info: "GIF picker invalid URL while \(action); context=\(queryContext)"
)
case .invalidResponse:
return .init(
user_visible_description: NSLocalizedString("The GIF service returned an unexpected response.", comment: "Error shown when the GIF picker receives an invalid server response."),
tip: NSLocalizedString("Try again in a moment. If it keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker receives an invalid server response."),
technical_info: "GIF picker invalid response while \(action); context=\(queryContext)"
)
case .decodingError(let decodingError, let rawResponse):
let responseText = rawResponse ?? "<unavailable>"
return .init(
user_visible_description: NSLocalizedString("We couldn't understand the GIF data from the server.", comment: "Error shown when GIF response parsing fails."),
tip: NSLocalizedString("Try again in a moment. If the problem continues, copy the technical information and send it to support.", comment: "Advice shown when GIF response parsing fails."),
technical_info: "GIF picker decoding error while \(action); context=\(queryContext); error=\(String(describing: decodingError)); response=\(responseText)"
)
case .networkError(let networkError):
return .init(
user_visible_description: NSLocalizedString("We couldn't reach the GIF service.", comment: "Error shown when GIF loading fails because of a network issue."),
tip: NSLocalizedString("Check your internet connection and try again.", comment: "Advice shown when GIF loading fails because of a network issue."),
technical_info: "GIF picker network error while \(action); context=\(queryContext); error=\(String(describing: networkError))"
)
case .upstreamError(let statusCode, let message):
return .init(
user_visible_description: NSLocalizedString("The GIF service is temporarily unavailable.", comment: "Error shown when the upstream GIF service fails."),
tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the upstream GIF service fails."),
technical_info: "GIF picker upstream error while \(action); context=\(queryContext); status=\(statusCode); message=\(message ?? "none")"
)
}
}
}
#Preview {
GIFPickerView(damus_state: test_damus_state) { url in
print("Selected GIF: \(url)")
}
}
@@ -0,0 +1,135 @@
//
// PurpleGIFAPIClient.swift
// damus
//
import Foundation
enum PurpleGIFAPIError: Error, LocalizedError {
case invalidURL
case invalidResponse
case unauthorized
case upstreamError(statusCode: Int, message: String?)
case networkError(Error)
case decodingError(Error, rawResponse: String?)
var errorDescription: String? {
switch self {
case .invalidURL:
return NSLocalizedString("Invalid GIF service URL", comment: "Error message for invalid Purple GIF URL")
case .invalidResponse:
return NSLocalizedString("Invalid response from GIF service", comment: "Error message for invalid Purple GIF response")
case .unauthorized:
return NSLocalizedString("Purple subscription required to use GIF search", comment: "Error message shown when the user is not authorized to use Purple GIF endpoints")
case .upstreamError(_, let message):
return message ?? NSLocalizedString("GIF service is temporarily unavailable", comment: "Error message shown when the Purple GIF service fails")
case .networkError(let error):
return error.localizedDescription
case .decodingError:
return NSLocalizedString("Failed to parse GIF data", comment: "Error message for GIF decoding failure")
}
}
}
actor PurpleGIFAPIClient {
/// The Purple proxy used to access KLIPY GIF endpoints.
let purple: DamusPurple
private let decoder = JSONDecoder()
/// Initializes a Purple-backed GIF API client.
init(purple: DamusPurple) {
self.purple = purple
}
/// Fetches featured GIFs from the Purple KLIPY proxy.
func fetchFeatured(limit: Int = 30, pos: String? = nil) async throws -> GIFSearchResponse {
var url = purple.environment.api_base_url()
url.append(path: "/gifs/featured")
url.append(queryItems: [
.init(name: "limit", value: String(limit))
])
if let pos, !pos.isEmpty {
url.append(queryItems: [.init(name: "pos", value: pos)])
}
return try await performRequest(url: url)
}
/// Searches GIFs from the Purple KLIPY proxy.
func search(query: String, page: Int = 1, perPage: Int = 30) async throws -> GIFSearchResponse {
var url = purple.environment.api_base_url()
url.append(path: "/gifs/search")
url.append(queryItems: [
.init(name: "q", value: query),
.init(name: "page", value: String(page)),
.init(name: "per_page", value: String(perPage))
])
return try await performRequest(url: url)
}
/// Performs an authenticated request against the Purple GIF proxy.
private func performRequest(url: URL) async throws -> GIFSearchResponse {
do {
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: purple.keypair
)
guard let httpResponse = response as? HTTPURLResponse else {
throw PurpleGIFAPIError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw mapHTTPError(statusCode: httpResponse.statusCode, data: data)
}
do {
return try decoder.decode(GIFSearchResponse.self, from: data)
} catch let decodingError as DecodingError {
let rawResponse = String(data: data, encoding: .utf8)
throw PurpleGIFAPIError.decodingError(decodingError, rawResponse: rawResponse)
}
} catch let error as PurpleGIFAPIError {
throw error
} catch {
throw PurpleGIFAPIError.networkError(error)
}
}
/// Maps HTTP failures from the Purple proxy into localized client errors.
private func mapHTTPError(statusCode: Int, data: Data) -> PurpleGIFAPIError {
if statusCode == 401 {
return .unauthorized
}
let message = extractErrorMessage(from: data)
return .upstreamError(statusCode: statusCode, message: message)
}
/// Extracts a human-readable error message from a JSON or text payload.
private func extractErrorMessage(from data: Data) -> String? {
guard !data.isEmpty else {
return nil
}
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let error = json["error"] as? String, !error.isEmpty {
return error
}
if let message = json["message"] as? String, !message.isEmpty {
return message
}
}
guard let text = String(data: data, encoding: .utf8), !text.isEmpty else {
return nil
}
return text
}
}
-112
View File
@@ -1,112 +0,0 @@
//
// 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
@@ -1,53 +0,0 @@
//
// 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
}
}