From 5caec855f4affbe3c0cc871fc6374175f18e2804 Mon Sep 17 00:00:00 2001 From: ericholguin Date: Mon, 19 Aug 2024 21:10:22 -0600 Subject: [PATCH] gif: Nostr Build GIF Keyboard This PR adds a gif keyboard, kind of, to the posting view. Leverages the nostr build API to get latest gifs. Changelog-Added: Nostr Build GIF keyboard Signed-off-by: ericholguin --- damus.xcodeproj/project.pbxproj | 24 +++ .../nostrbuild.imageset/Contents.json | 12 ++ .../nb-logo_nb-logo-color.svg | 1 + damus/Models/GIFs/NostrBuildGIF.swift | 62 +++++++ damus/Util/Log.swift | 1 + damus/Views/GIFs/NostrBuildGIFGrid.swift | 163 ++++++++++++++++++ damus/Views/PostView.swift | 47 ++++- 7 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 damus/Assets.xcassets/nostrbuild.imageset/Contents.json create mode 100644 damus/Assets.xcassets/nostrbuild.imageset/nb-logo_nb-logo-color.svg create mode 100644 damus/Models/GIFs/NostrBuildGIF.swift create mode 100644 damus/Views/GIFs/NostrBuildGIFGrid.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 349dd9859..8f641d0be 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -408,6 +408,8 @@ 5C7389B72B9E692E00781E0A /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.swift */; }; 5C7389B92B9E69ED00781E0A /* MutinyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */; }; 5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */; }; + 5C8711E12C6D8912007879C2 /* NostrBuildGIF.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711E02C6D8912007879C2 /* NostrBuildGIF.swift */; }; + 5C8711E42C6D8C86007879C2 /* NostrBuildGIFGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711E32C6D8C86007879C2 /* NostrBuildGIFGrid.swift */; }; 5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */; }; 5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */; }; 5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */; }; @@ -1350,6 +1352,8 @@ 5C7389B62B9E692E00781E0A /* MutinyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyButton.swift; sourceTree = ""; }; 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyGradient.swift; sourceTree = ""; }; 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineView.swift; sourceTree = ""; }; + 5C8711E02C6D8912007879C2 /* NostrBuildGIF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrBuildGIF.swift; sourceTree = ""; }; + 5C8711E32C6D8C86007879C2 /* NostrBuildGIFGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrBuildGIFGrid.swift; sourceTree = ""; }; 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightEvent.swift; sourceTree = ""; }; 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightView.swift; sourceTree = ""; }; 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDescription.swift; sourceTree = ""; }; @@ -1623,6 +1627,7 @@ 4C0A3F8D280F63FF000448DE /* Models */ = { isa = PBXGroup; children = ( + 5C8711DF2C6D88F6007879C2 /* GIFs */, D74F43082B23F09300425B75 /* Purple */, BA3759882ABCCDE30018D73B /* Camera */, 4C190F1E2A535FC200027FD5 /* Zaps */, @@ -2023,6 +2028,7 @@ 4C75EFA227FA576C0006080F /* Views */ = { isa = PBXGroup; children = ( + 5C8711E22C6D8C6E007879C2 /* GIFs */, D78DB85D2C20FE9E00F0AB12 /* Chat */, D71AC4CA2BA8E3320076268E /* Extensions */, BA3759952ABCCF360018D73B /* Camera */, @@ -2716,6 +2722,22 @@ path = Images; sourceTree = ""; }; + 5C8711DF2C6D88F6007879C2 /* GIFs */ = { + isa = PBXGroup; + children = ( + 5C8711E02C6D8912007879C2 /* NostrBuildGIF.swift */, + ); + path = GIFs; + sourceTree = ""; + }; + 5C8711E22C6D8C6E007879C2 /* GIFs */ = { + isa = PBXGroup; + children = ( + 5C8711E32C6D8C86007879C2 /* NostrBuildGIFGrid.swift */, + ); + path = GIFs; + sourceTree = ""; + }; 5CC852A02BDED9970039FFC5 /* Highlight */ = { isa = PBXGroup; children = ( @@ -3426,6 +3448,7 @@ 5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */, 4CC14FF52A740BB7007AEB17 /* NoteId.swift in Sources */, 4C19AE512A5CEF7C00C90DB7 /* NostrScript.swift in Sources */, + 5C8711E42C6D8C86007879C2 /* NostrBuildGIFGrid.swift in Sources */, 4C32B95E2A9AD44700DC3548 /* FlatBufferObject.swift in Sources */, D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */, 4C3EA64F28FF59F200C48A62 /* tal.c in Sources */, @@ -3477,6 +3500,7 @@ D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */, 3165648B295B70D500C64604 /* LinkView.swift in Sources */, 4C8D00CF29E38B950036AF10 /* nostr_bech32.c in Sources */, + 5C8711E12C6D8912007879C2 /* NostrBuildGIF.swift in Sources */, D7CB5D5C2B1176B200AD4105 /* MediaUploader.swift in Sources */, 4C1253562A76C8C60004F4B8 /* BroadcastNotify.swift in Sources */, 4C3BEFD42819DE8F00B3DE84 /* NostrKind.swift in Sources */, diff --git a/damus/Assets.xcassets/nostrbuild.imageset/Contents.json b/damus/Assets.xcassets/nostrbuild.imageset/Contents.json new file mode 100644 index 000000000..e5624fa41 --- /dev/null +++ b/damus/Assets.xcassets/nostrbuild.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "nb-logo_nb-logo-color.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/damus/Assets.xcassets/nostrbuild.imageset/nb-logo_nb-logo-color.svg b/damus/Assets.xcassets/nostrbuild.imageset/nb-logo_nb-logo-color.svg new file mode 100644 index 000000000..34c914545 --- /dev/null +++ b/damus/Assets.xcassets/nostrbuild.imageset/nb-logo_nb-logo-color.svg @@ -0,0 +1 @@ + diff --git a/damus/Models/GIFs/NostrBuildGIF.swift b/damus/Models/GIFs/NostrBuildGIF.swift new file mode 100644 index 000000000..daf30cf5a --- /dev/null +++ b/damus/Models/GIFs/NostrBuildGIF.swift @@ -0,0 +1,62 @@ +// +// NostrBuildGIF.swift +// damus +// +// Created by eric on 8/14/24. +// + +import Foundation + +let pageSize: Int = 30 + +func makeGIFRequest(cursor: Int) async throws -> NostrBuildGIFResponse { + var request = URLRequest(url: URL(string: String(format: "https://nostr.build/api/v2/gifs/get?cursor=%d&limit=%d&random=%d", + cursor, + pageSize, + 0))!) + + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let response: NostrBuildGIFResponse = try await decodedData(for: request) + return response +} + +private func decodedData(for request: URLRequest) async throws -> Output { + let decoder = JSONDecoder() + let session = URLSession.shared + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + let result = try decoder.decode(Output.self, from: data) + return result + default: + Log.error("Error retrieving gif data from Nostr Build. HTTP status code: %d; Response: %s", for: .gif_request, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") + throw NostrBuildError.http_response_error(status_code: httpResponse.statusCode, response: data) + } + } + + throw NostrBuildError.could_not_process_response +} + +enum NostrBuildError: Error { + case http_response_error(status_code: Int, response: Data) + case could_not_process_response +} + +struct NostrBuildGIFResponse: Codable { + let status: String + let message: String + let cursor: Int + let count: Int + let gifs: [NostrBuildGif] +} + +struct NostrBuildGif: Codable, Identifiable { + var id: String { bh } + var url: String + /// This is the blurhash of the gif that can be used as an ID and placeholder + let bh: String +} diff --git a/damus/Util/Log.swift b/damus/Util/Log.swift index d8a3208e8..4220302bd 100644 --- a/damus/Util/Log.swift +++ b/damus/Util/Log.swift @@ -16,6 +16,7 @@ enum LogCategory: String { case push_notifications case damus_purple case image_uploading + case gif_request } /// Damus structured logger diff --git a/damus/Views/GIFs/NostrBuildGIFGrid.swift b/damus/Views/GIFs/NostrBuildGIFGrid.swift new file mode 100644 index 000000000..44ea6aa31 --- /dev/null +++ b/damus/Views/GIFs/NostrBuildGIFGrid.swift @@ -0,0 +1,163 @@ +// +// NostrBuildGIFGrid.swift +// damus +// +// Created by eric on 8/14/24. +// + +import SwiftUI +import Kingfisher + + +struct NostrBuildGIFGrid: View { + let damus_state: DamusState + @State var results:[NostrBuildGif] = [] + @State var cursor: Int = 0 + @State var errorAlert: Bool = false + @SceneStorage("NostrBuildGIFGrid.show_nsfw_alert") var show_nsfw_alert : Bool = true + @SceneStorage("NostrBuildGIFGrid.persist_nsfw_alert") var persist_nsfw_alert : Bool = true + @Environment(\.dismiss) var dismiss + + var onSelect:(String) -> () + + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + + var TopBar: some View { + VStack { + HStack(spacing: 5.0) { + + Button(action: { + Task { + cursor -= pageSize + do { + let response = try await makeGIFRequest(cursor: cursor) + self.results = response.gifs + } catch { + print(error.localizedDescription) + } + } + }, label: { + Text("Back", comment: "Button to go to previous page.") + .padding(10) + }) + .buttonStyle(NeutralButtonStyle()) + .opacity(cursor > 0 ? 1 : 0) + .disabled(cursor == 0) + + Spacer() + + Image("nostrbuild") + .resizable() + .frame(width: 40, height: 40) + + Spacer() + + Button(NSLocalizedString("Next", comment: "Button to go to next page.")) { + Task { + cursor += pageSize + do { + let response = try await makeGIFRequest(cursor: cursor) + self.results = response.gifs + } catch { + print(error.localizedDescription) + } + } + } + .bold() + .buttonStyle(GradientButtonStyle(padding: 10)) + } + + Divider() + .foregroundColor(DamusColors.neutral3) + .padding(.top, 5) + } + .frame(height: 30) + .padding() + .padding(.top, 15) + } + + var body: some View { + VStack { + TopBar + ScrollView { + LazyVGrid(columns: columns, spacing: 5) { + ForEach($results) { gifResult in + VStack { + if let url = URL(string: gifResult.url.wrappedValue) { + ZStack { + KFAnimatedImage(url) + .imageContext(.note, disable_animation: damus_state.settings.disable_animation) + .cancelOnDisappear(true) + .configure { view in + view.framePreloadCount = 3 + } + .clipShape(RoundedRectangle(cornerRadius: 12.0)) + .frame(width: 120, height: 120) + .aspectRatio(contentMode: .fill) + .onTapGesture { + onSelect(url.absoluteString) + dismiss() + } + if persist_nsfw_alert { + Blur() + } + } + } + } + } + } + Spacer() + } + } + .padding() + .alert("Error", isPresented: $errorAlert) { + Button(NSLocalizedString("OK", comment: "Exit this view")) { + dismiss() + } + } message: { + Text("Failed to load GIFs") + } + .alert("NSFW", isPresented: $show_nsfw_alert) { + Button(NSLocalizedString("Cancel", comment: "Exit this view")) { + dismiss() + } + Button(NSLocalizedString("Proceed", comment: "Button to continue")) { + show_nsfw_alert = false + persist_nsfw_alert = false + } + } message: { + Text("NSFW means \"Not Safe For Work\". The content in this view may be inappropriate to view in some situations and may contain explicit images.", comment: "Warning to the user that there may be content that is not safe for work.") + } + .onAppear { + Task { + await initial() + } + if persist_nsfw_alert { + show_nsfw_alert = true + } + } + } + + func initial() async { + do { + let response = try await makeGIFRequest(cursor: cursor) + self.results = response.gifs + } catch { + print(error) + errorAlert = true + } + + } +} + +struct NostrBuildGIFGrid_Previews: PreviewProvider { + static var previews: some View { + NostrBuildGIFGrid(damus_state: test_damus_state) { gifURL in + print("GIF URL: \(gifURL)") + } + } +} diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift index 2f8c3e723..7c35e3402 100644 --- a/damus/Views/PostView.swift +++ b/damus/Views/PostView.swift @@ -48,6 +48,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 uploadedMedias: [UploadedMedia] = [] @State var image_upload_confirm: Bool = false @@ -159,10 +160,20 @@ 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 + GIFButton } .disabled(uploading_disabled) } @@ -319,7 +330,7 @@ struct PostView: View { switch res { case .success(let url): guard let url = URL(string: url) else { - self.error = "Error uploading image :(" + self.error = UploadError.image_error.errorDescription return } let blurhash = await blurhash @@ -331,7 +342,7 @@ struct PostView: View { if let error { self.error = error.localizedDescription } else { - self.error = "Error uploading image :(" + self.error = UploadError.image_error.errorDescription } } @@ -450,6 +461,25 @@ struct PostView: View { self.attach_media = true } } + .sheet(isPresented: $attach_gif) { + NostrBuildGIFGrid(damus_state: damus_state) { gifUrl in + guard let url = URL(string: gifUrl) else { + self.error = UploadError.gif_error.errorDescription + return + } + self.preUploadedMedia = PreUploadedMedia.processed_image(url) + if let media = generateMediaUpload(preUploadedMedia) { + let img = getImage(media: media) + Task { + async let blurhash = calculate_blurhash(img: img) + let meta = await blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) } + let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta) + uploadedMedias.append(uploadedMedia) + } + } + } + .presentationDragIndicator(.visible) + } .onAppear() { let loaded_draft = load_draft() @@ -588,6 +618,19 @@ struct UploadedMedia: Equatable { let metadata: ImageMetadata? } +enum UploadError: LocalizedError { + case image_error + case gif_error + + var errorDescription: String? { + switch self { + case .image_error: + return NSLocalizedString("Error uploading image :(", comment: "Error message indicating that there was an error while uploading image.") + case .gif_error: + return NSLocalizedString("Error uploading gif :(", comment: "Error message indicating that there was an error while uploading gif.") + } + } +} func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArtifacts) { switch action {