diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 6baa5010..f34a3d34 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -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 = ""; }; 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = ""; }; 5CF72FC129B9142F00124A13 /* ShareAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAction.swift; sourceTree = ""; }; + 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorModels.swift; sourceTree = ""; }; + 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFPickerView.swift; sourceTree = ""; }; + 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorAPIClient.swift; sourceTree = ""; }; + 5CFDE6F42EF4F939004E8661 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; 6439E013296790CF0020672B /* ProfilePicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePicImageView.swift; sourceTree = ""; }; 643EA5C7296B764E005081BB /* RelayFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterView.swift; sourceTree = ""; }; 647D9A8C2968520300A295DE /* SideMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuView.swift; sourceTree = ""; }; @@ -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 = ""; }; + 5CFDE6E32EF4F773004E8661 /* GIF */ = { + isa = PBXGroup; + children = ( + 5CFDE6F02EF4F7E2004E8661 /* TenorAPIClient.swift */, + 5CFDE6EC2EF4F7AB004E8661 /* GIFPickerView.swift */, + 5CFDE6E42EF4F77E004E8661 /* TenorModels.swift */, + ); + path = GIF; + sourceTree = ""; + }; 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 */, diff --git a/damus/Features/Labs/Views/DamusLabsExperiments.swift b/damus/Features/Labs/Views/DamusLabsExperiments.swift index 9acbf359..5e943947 100644 --- a/damus/Features/Labs/Views/DamusLabsExperiments.swift +++ b/damus/Features/Labs/Views/DamusLabsExperiments.swift @@ -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")) + } } } diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index d7ad7e64..bedc8ad9 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -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) { diff --git a/damus/Features/Settings/Models/UserSettingsStore.swift b/damus/Features/Settings/Models/UserSettingsStore.swift index ae2c6aa7..ce98af90 100644 --- a/damus/Features/Settings/Models/UserSettingsStore.swift +++ b/damus/Features/Settings/Models/UserSettingsStore.swift @@ -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 diff --git a/damus/Features/Settings/Views/AppearanceSettingsView.swift b/damus/Features/Settings/Views/AppearanceSettingsView.swift index 13278728..dfafa56b 100644 --- a/damus/Features/Settings/Views/AppearanceSettingsView.swift +++ b/damus/Features/Settings/Views/AppearanceSettingsView.swift @@ -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."), diff --git a/damus/Secrets.swift b/damus/Secrets.swift new file mode 100644 index 00000000..b27a607d --- /dev/null +++ b/damus/Secrets.swift @@ -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"] +} diff --git a/damus/Shared/Media/GIF/GIFPickerView.swift b/damus/Shared/Media/GIF/GIFPickerView.swift new file mode 100644 index 00000000..68c3581f --- /dev/null +++ b/damus/Shared/Media/GIF/GIFPickerView.swift @@ -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? + + 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)") + } +} + + diff --git a/damus/Shared/Media/GIF/TenorAPIClient.swift b/damus/Shared/Media/GIF/TenorAPIClient.swift new file mode 100644 index 00000000..2863f184 --- /dev/null +++ b/damus/Shared/Media/GIF/TenorAPIClient.swift @@ -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) + } + } +} diff --git a/damus/Shared/Media/GIF/TenorModels.swift b/damus/Shared/Media/GIF/TenorModels.swift new file mode 100644 index 00000000..4e2f028b --- /dev/null +++ b/damus/Shared/Media/GIF/TenorModels.swift @@ -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 + } +}