From eb3931bfdf6100a4952d8ecb14c9cfb2fbfe0939 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sat, 1 Feb 2025 16:20:01 +0100 Subject: [PATCH] Feature: Post & File Upload (#31) * upload images to void.cat when using UploadComponent * enable upload in bottom bar * rm link in content (used to be like this for kind 1 but not for kind 20) * add blurhash and other tags + refactor UploadComponent * add blurhash dependency * move Global next to Follower Feed in Bottom Bar * refactor UploadComponent to extract signEvent function for better readability and maintainability * check uploaded note by fetching it. showing this in a drawer for now * return null when logged in with amber web-style because its not implemented yet * move Nostr Event signing to utils * change file upload from void.cat to nostr.download --- lumina/components/BottomBar.tsx | 12 +- lumina/components/UploadComponent.tsx | 398 ++++++++++++++++---------- lumina/components/spinner.tsx | 20 ++ lumina/package-lock.json | 7 + lumina/package.json | 1 + lumina/utils/utils.ts | 39 ++- 6 files changed, 313 insertions(+), 164 deletions(-) create mode 100644 lumina/components/spinner.tsx diff --git a/lumina/components/BottomBar.tsx b/lumina/components/BottomBar.tsx index dd11405..2576f6a 100644 --- a/lumina/components/BottomBar.tsx +++ b/lumina/components/BottomBar.tsx @@ -47,16 +47,16 @@ export default function BottomBar() { Follower Feed )} - {/* {pubkey && window.localStorage.getItem('loginType') != 'readOnly_npub' && ( - - - Upload - - )} */} Global + {pubkey && window.localStorage.getItem('loginType') != 'readOnly_npub' && ( + + + Upload + + )} Search diff --git a/lumina/components/UploadComponent.tsx b/lumina/components/UploadComponent.tsx index 107cca0..e590f4c 100644 --- a/lumina/components/UploadComponent.tsx +++ b/lumina/components/UploadComponent.tsx @@ -1,205 +1,261 @@ -import { useNostr } from 'nostr-react'; -import { finalizeEvent, nip19, NostrEvent } from 'nostr-tools'; -import React, { ChangeEvent, FormEvent, useState } from 'react'; -import { Button } from './ui/button'; -import { Textarea } from './ui/textarea'; -import { bytesToHex, hexToBytes } from '@noble/hashes/utils' -import { ReloadIcon } from '@radix-ui/react-icons'; -import { Label } from './ui/label'; -import { Input } from './ui/input'; +import { useNostr, useNostrEvents } from "nostr-react" +import { finalizeEvent, nip19, type NostrEvent } from "nostr-tools" +import type React from "react" +import { type ChangeEvent, type FormEvent, useState, useEffect, useCallback } from "react" +import { Button } from "./ui/button" +import { Textarea } from "./ui/textarea" +import { bytesToHex, hexToBytes } from "@noble/hashes/utils" +import { ReloadIcon } from "@radix-ui/react-icons" +import { Label } from "./ui/label" +import { Input } from "./ui/input" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { encode } from "blurhash" import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion" + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerDescription, + DrawerFooter, +} from "@/components/ui/drawer" +import { Spinner } from "@/components/spinner" +import { signEvent } from "@/utils/utils" + +async function calculateBlurhash(file: File): Promise { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + const img = new Image() + img.onload = () => { + canvas.width = 32 + canvas.height = 32 + ctx?.drawImage(img, 0, 0, 32, 32) + const imageData = ctx?.getImageData(0, 0, 32, 32) + if (imageData) { + const blurhash = encode(imageData.data, imageData.width, imageData.height, 4, 4) + resolve(blurhash) + } else { + reject(new Error("Failed to get image data")) + } + } + img.onerror = reject + img.src = URL.createObjectURL(file) + }) +} const UploadComponent: React.FC = () => { + const { publish } = useNostr() + const { createHash } = require("crypto") + const loginType = typeof window !== "undefined" ? window.localStorage.getItem("loginType") : null + const [previewUrl, setPreviewUrl] = useState("") - const { publish } = useNostr(); - const { createHash } = require('crypto'); - const loginType = typeof window !== 'undefined' ? window.localStorage.getItem('loginType') : null; - const [previewUrl, setPreviewUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false) + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + const [uploadedNoteId, setUploadedNoteId] = useState("") + const [retryCount, setRetryCount] = useState(0) + const [shouldFetch, setShouldFetch] = useState(false) - const [isLoading, setIsLoading] = useState(false); + const { events, isLoading: isNoteLoading } = useNostrEvents({ + filter: shouldFetch + ? { + ids: uploadedNoteId ? [uploadedNoteId] : [], + kinds: [20], + limit: 1, + } + : { ids: [], kinds: [20], limit: 1 }, + enabled: shouldFetch, + }) + + useEffect(() => { + if (uploadedNoteId) { + setShouldFetch(true) + } + }, [uploadedNoteId]) + + useEffect(() => { + let timeoutId: NodeJS.Timeout + + if (shouldFetch && events.length === 0 && !isNoteLoading) { + timeoutId = setTimeout(() => { + setRetryCount((prevCount) => prevCount + 1) + setShouldFetch(false) + setShouldFetch(true) + }, 5000) // Retry every 5 seconds + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }, [shouldFetch, events, isNoteLoading]) + + const handleRetry = useCallback(() => { + setRetryCount((prevCount) => prevCount + 1) + setShouldFetch(false) + setShouldFetch(true) + }, []) const handleFileChange = (event: ChangeEvent) => { - const file = event.target.files?.[0]; + const file = event.target.files?.[0] if (file) { - const url = URL.createObjectURL(file); - if (url.startsWith('blob:')) { - setPreviewUrl(url); + const url = URL.createObjectURL(file) + if (url.startsWith("blob:")) { + setPreviewUrl(url) } // Optional: Bereinigung alter URLs - return () => URL.revokeObjectURL(url); + return () => URL.revokeObjectURL(url) } - }; + } async function onSubmit(event: FormEvent) { - event.preventDefault(); - setIsLoading(true); + event.preventDefault() + setIsLoading(true) - const formData = new FormData(event.currentTarget); - const desc = formData.get('description') as string; - const file = formData.get('file') as File; - let sha256 = ''; - let finalNoteContent = desc; - let finalFileUrl = ''; - console.log('File:', file); + const formData = new FormData(event.currentTarget) + const desc = formData.get("description") as string + const file = formData.get("file") as File + let sha256 = "" + let finalNoteContent = desc + let finalFileUrl = "" + console.log("File:", file) if (!desc && !file.size) { - alert('Please enter a description and/or upload a file'); - setIsLoading(false); - return; + alert("Please enter a description and/or upload a file") + setIsLoading(false) + return } // get every hashtag in desc and cut off the # symbol - let hashtags: string[] = desc.match(/#[a-zA-Z0-9]+/g) || []; + let hashtags: string[] = desc.match(/#[a-zA-Z0-9]+/g) || [] if (hashtags) { - hashtags = hashtags.map((hashtag) => hashtag.slice(1)); + hashtags = hashtags.map((hashtag) => hashtag.slice(1)) } - - // If file is is preent, upload it to the media server + // If file is present, upload it to the media server if (file) { const readFileAsArrayBuffer = (file: File): Promise => { return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as ArrayBuffer); - reader.onerror = () => reject(reader.error); - reader.readAsArrayBuffer(file); - }); - }; + const reader = new FileReader() + reader.onload = () => resolve(reader.result as ArrayBuffer) + reader.onerror = () => reject(reader.error) + reader.readAsArrayBuffer(file) + }) + } try { - const arrayBuffer = await readFileAsArrayBuffer(file); - const hashBuffer = createHash('sha256').update(Buffer.from(arrayBuffer)).digest(); - sha256 = hashBuffer.toString('hex'); + const arrayBuffer = await readFileAsArrayBuffer(file) + const hashBuffer = createHash("sha256").update(Buffer.from(arrayBuffer)).digest() + sha256 = hashBuffer.toString("hex") - const unixNow = () => Math.floor(Date.now() / 1000); - const newExpirationValue = () => (unixNow() + 60 * 5).toString(); + const unixNow = () => Math.floor(Date.now() / 1000) + const newExpirationValue = () => (unixNow() + 60 * 5).toString() - const pubkey = window.localStorage.getItem('pubkey'); - const createdAt = Math.floor(Date.now() / 1000); + const pubkey = window.localStorage.getItem("pubkey") + const createdAt = Math.floor(Date.now() / 1000) // Create auth event for blossom auth via nostr - let authEvent = { + const authEvent: NostrEvent = { kind: 24242, content: desc, created_at: createdAt, tags: [ - ['t', 'upload'], - ['x', sha256], - ['expiration', newExpirationValue()], + ["t", "upload"], + ["x", sha256], + ["expiration", newExpirationValue()], ], - }; + pubkey: "", // Add a placeholder for pubkey + id: "", // Add a placeholder for id + sig: "", // Add a placeholder for sig + } - console.log(authEvent); + console.log(authEvent) // Sign auth event - let authEventSigned = {}; - if (loginType === 'extension') { - authEventSigned = await window.nostr.signEvent(authEvent); - } else if (loginType === 'amber') { - // TODO: Sign event with amber - alert('Signing with Amber is not implemented yet, sorry!'); - } else if (loginType === 'raw_nsec') { - if (typeof window !== 'undefined') { - let nsecStr = null; - nsecStr = window.localStorage.getItem('nsec'); - if (nsecStr != null) { - authEventSigned = finalizeEvent(authEvent, hexToBytes(nsecStr)); - } - } - } - console.log(authEventSigned); + let authEventSigned = (await signEvent(loginType, authEvent)) as NostrEvent // Actually upload the file - await fetch('https://media.lumina.rocks/upload', { - method: 'PUT', + await fetch("https://nostr.download/upload", { + method: "PUT", body: file, - headers: { authorization: 'Nostr ' + btoa(JSON.stringify(authEventSigned)) }, + headers: { authorization: "Nostr " + btoa(JSON.stringify(authEventSigned)) }, }).then(async (res) => { if (res.ok) { - let responseText = await res.text(); - let responseJson = JSON.parse(responseText); - finalFileUrl = responseJson.url; + const responseText = await res.text() + const responseJson = JSON.parse(responseText) + finalFileUrl = responseJson.url } else { - alert(await res.text()); + alert(await res.text()) } - }); + }) } catch (error) { - console.error('Error reading file:', error); + alert("Error: " + error) + console.error("Error reading file:", error) } } - let noteTags = hashtags.map((tag) => ['t', tag]); + const noteTags = hashtags.map((tag) => ["t", tag]) - // If we have a file, add the file url to the note content - // and also to the note tags imeta - // "tags": [ - // [ - // "imeta", - // "url https://nostr.build/i/my-image.jpg", - // "m image/jpeg", - // "blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$", - // "dim 3024x4032", - // "alt A scenic photo overlooking the coast of Costa Rica", - // "x ", - // "fallback https://nostrcheck.me/alt1.jpg", - // "fallback https://void.cat/alt1.jpg" - // ] - // ] - if (finalFileUrl) { - // convert file into image - const image = new Image(); - image.src = URL.createObjectURL(file); - - finalNoteContent = finalFileUrl + ' ' + desc; - noteTags.push(['imeta', 'url ' + finalFileUrl, 'm ' + file.type, 'x ' + sha256, 'ox ' + sha256]); + let blurhash = "" + if (file && file.type.startsWith("image/")) { + try { + blurhash = await calculateBlurhash(file) + } catch (error) { + console.error("Error calculating blurhash:", error) + } } - const createdAt = Math.floor(Date.now() / 1000); + if (finalFileUrl) { + const image = new Image() + image.src = URL.createObjectURL(file) + await new Promise((resolve) => { + image.onload = resolve + }) + finalNoteContent = desc + noteTags.push([ + "imeta", + "url " + finalFileUrl, + "m " + file.type, + "x " + sha256, + "ox " + sha256, + "blurhash " + blurhash, + `dim ${image.width}x${image.height}`, + ]) + } + + const createdAt = Math.floor(Date.now() / 1000) // Create the actual note - let noteEvent = { - kind: 1, + const noteEvent: NostrEvent = { + kind: 20, content: finalNoteContent, created_at: createdAt, tags: noteTags, - }; - - let signedEvent: NostrEvent | null = null; - - // Sign the actual note - if (loginType === 'extension') { - signedEvent = await window.nostr.signEvent(noteEvent); - } else if (loginType === 'amber') { - // TODO: Sign event with amber - alert('Signing with Amber is not implemented yet, sorry!'); - } else if (loginType === 'raw_nsec') { - if (typeof window !== 'undefined') { - let nsecStr = null; - nsecStr = window.localStorage.getItem('nsec'); - if (nsecStr != null) { - signedEvent = finalizeEvent(noteEvent, hexToBytes(nsecStr)); - } - } + pubkey: "", // Add a placeholder for pubkey + id: "", // Add a placeholder for id + sig: "", // Add a placeholder for sig } - // If the got a signed event, publish it to nostr + let signedEvent: NostrEvent | null = null + + // Sign the actual note + signedEvent = (await signEvent(loginType, noteEvent)) as NostrEvent + + // If we got a signed event, publish it to nostr if (signedEvent) { console.log("final Event: ") console.log(signedEvent) - publish(signedEvent); + publish(signedEvent) } - // Redirect to the note - setIsLoading(false); + setIsLoading(false) if (signedEvent != null) { - window.location.href = '/note/' + nip19.noteEncode(signedEvent.id); + setUploadedNoteId(signedEvent.id) + setIsDrawerOpen(true) + setShouldFetch(true) + setRetryCount(0) } } @@ -207,27 +263,69 @@ const UploadComponent: React.FC = () => { <>
- - - - Image Upload - -
- -
- {previewUrl && Preview} -
-
-
+ +
+ +
+ {previewUrl && Preview} {isLoading ? ( - + ) : ( - + )}
+ + + + Upload Status + + {isNoteLoading ? ( +
+ + Checking note status... +
+ ) : events.length > 0 ? ( +
+ Success! + Note found with ID: + + {`${events[0].id.slice(0, 5)}...${events[0].id.slice(-3)}`} + +
+ ) : ( +

Note not found. It may take a moment to propagate.

+ )} +
+
+ + {events.length === 0 && ( + + )} + + + +
+
- ); + ) } -export default UploadComponent; \ No newline at end of file +export default UploadComponent \ No newline at end of file diff --git a/lumina/components/spinner.tsx b/lumina/components/spinner.tsx new file mode 100644 index 0000000..942e152 --- /dev/null +++ b/lumina/components/spinner.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils" + +export function Spinner({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/lumina/package-lock.json b/lumina/package-lock.json index 99c1357..902b17e 100644 --- a/lumina/package-lock.json +++ b/lumina/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", + "blurhash": "^2.0.5", "bolt11": "^1.4.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -4155,6 +4156,12 @@ "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==" }, + "node_modules/blurhash": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", diff --git a/lumina/package.json b/lumina/package.json index 7c288ee..94568e0 100644 --- a/lumina/package.json +++ b/lumina/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", + "blurhash": "^2.0.5", "bolt11": "^1.4.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/lumina/utils/utils.ts b/lumina/utils/utils.ts index b986d7e..45fd701 100644 --- a/lumina/utils/utils.ts +++ b/lumina/utils/utils.ts @@ -1,15 +1,16 @@ -import { Event as NostrEvent } from "nostr-tools"; +import { Event as NostrEvent, finalizeEvent} from "nostr-tools"; +import { hexToBytes } from "@noble/hashes/utils" export function getImageUrl(tags: string[][]): string { - const imetaTag = tags.find(tag => tag[0] === 'imeta'); - if (imetaTag) { - const urlItem = imetaTag.find(item => item.startsWith('url ')); - if (urlItem) { - return urlItem.split(' ')[1]; - } + const imetaTag = tags.find(tag => tag[0] === 'imeta'); + if (imetaTag) { + const urlItem = imetaTag.find(item => item.startsWith('url ')); + if (urlItem) { + return urlItem.split(' ')[1]; } - return ''; } + return ''; +} export function extractDimensions(event: NostrEvent): { width: number; height: number } { const imetaTag = event.tags.find(tag => tag[0] === 'imeta'); @@ -21,4 +22,26 @@ export function extractDimensions(event: NostrEvent): { width: number; height: n } } return { width: 500, height: 300 }; // Default dimensions if not found +} + +export async function signEvent(loginType: string | null, event: NostrEvent): Promise { + // Sign event + let eventSigned: NostrEvent = { ...event, sig: '' }; + if (loginType === 'extension') { + eventSigned = await window.nostr.signEvent(event); + } else if (loginType === 'amber') { + // TODO: Sign event with amber + alert('Signing with Amber is not implemented yet, sorry!'); + return null; + } else if (loginType === 'raw_nsec') { + if (typeof window !== 'undefined') { + let nsecStr = null; + nsecStr = window.localStorage.getItem('nsec'); + if (nsecStr != null) { + eventSigned = finalizeEvent(event, hexToBytes(nsecStr)); + } + } + } + console.log(eventSigned); + return eventSigned; } \ No newline at end of file