diff --git a/package-lock.json b/package-lock.json index d33975f..db084cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "applesauce-react": "^5.0.1", "applesauce-relay": "^5.0.0", "applesauce-signers": "^5.0.0", + "blossom-client-sdk": "^4.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -5781,6 +5782,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/blossom-client-sdk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/blossom-client-sdk/-/blossom-client-sdk-4.1.0.tgz", + "integrity": "sha512-IEjX3/e6EYnEonlog8qbd1/7qYIatOKEAQMWGkPCPjTO/b9fsrSnoELwOam52a5U3M83XLvYFhf6qE9MmlmJuQ==", + "license": "MIT", + "dependencies": { + "@cashu/cashu-ts": "^2.4.3", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/blossom-client-sdk/node_modules/@cashu/cashu-ts": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz", + "integrity": "sha512-4HO3LC3VqiMs0K7ccQdfSs3l1wJNL0VuE8ZQ6zAfMsoeKRwswA1eC5BaGFrEDv7PcPqjliE/RBRw3+1Hz/SmsA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.9.5", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0" + }, + "engines": { + "node": ">=22.4.0" + } + }, + "node_modules/blossom-client-sdk/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/blossom-client-sdk/node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/package.json b/package.json index 79ed59a..bda4b9b 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "applesauce-react": "^5.0.1", "applesauce-relay": "^5.0.0", "applesauce-signers": "^5.0.0", + "blossom-client-sdk": "^4.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/src/components/BlossomUploadDialog.tsx b/src/components/BlossomUploadDialog.tsx new file mode 100644 index 0000000..c28ef9e --- /dev/null +++ b/src/components/BlossomUploadDialog.tsx @@ -0,0 +1,553 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { toast } from "sonner"; +import { + Upload, + Loader2, + HardDrive, + Image as ImageIcon, + Film, + Music, + FileIcon, + FileText, + Archive, + CheckCircle, + XCircle, + Globe, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useEventStore } from "applesauce-react/hooks"; +import { use$ } from "applesauce-react/hooks"; +import accountManager from "@/services/accounts"; +import { addressLoader } from "@/services/loaders"; +import { + USER_SERVER_LIST_KIND, + getServersFromEvent, + uploadBlobToServers, + type UploadResult, +} from "@/services/blossom"; +import type { Subscription } from "rxjs"; + +/** + * Well-known public Blossom servers that can be used as fallbacks + * when the user doesn't have their own server list configured + */ +const FALLBACK_SERVERS = [ + "https://blossom.primal.net", + "https://nostr.download", +]; + +interface BlossomUploadDialogProps { + /** Whether the dialog is open */ + open: boolean; + /** Callback when dialog open state changes */ + onOpenChange: (open: boolean) => void; + /** Called when upload completes successfully */ + onSuccess: (results: UploadResult[]) => void; + /** Called when upload is cancelled */ + onCancel?: () => void; + /** Called when upload fails */ + onError?: (error: Error) => void; + /** File types to accept (e.g., "image/*,video/*,audio/*") */ + accept?: string; +} + +/** + * BlossomUploadDialog - Modal dialog for uploading files to Blossom servers + * + * Features: + * - File selection with drag & drop support + * - Server selection from user's kind 10063 list + * - Upload progress and results + * - Preview for images/video/audio + */ +export function BlossomUploadDialog({ + open, + onOpenChange, + onSuccess, + onCancel, + onError, + accept = "image/*,video/*,audio/*", +}: BlossomUploadDialogProps) { + const eventStore = useEventStore(); + const activeAccount = use$(accountManager.active$); + const pubkey = activeAccount?.pubkey; + + const [servers, setServers] = useState([]); + const [selectedServers, setSelectedServers] = useState>( + new Set(), + ); + const [loadingServers, setLoadingServers] = useState(true); + const [usingFallback, setUsingFallback] = useState(false); + const [uploading, setUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [uploadResults, setUploadResults] = useState([]); + const [uploadErrors, setUploadErrors] = useState< + { server: string; error: string }[] + >([]); + const fileInputRef = useRef(null); + const dropZoneRef = useRef(null); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSelectedFile(null); + setPreviewUrl(null); + setUploadResults([]); + setUploadErrors([]); + setUploading(false); + setUsingFallback(false); + } + }, [open]); + + // Helper to set fallback servers + const applyFallbackServers = useCallback(() => { + setServers(FALLBACK_SERVERS); + setSelectedServers(new Set([FALLBACK_SERVERS[0]])); // Select first by default + setUsingFallback(true); + setLoadingServers(false); + }, []); + + // Fetch servers when dialog opens + useEffect(() => { + if (!open) { + setLoadingServers(false); + return; + } + + // If no pubkey (not logged in), can't upload - auth required + if (!pubkey) { + setLoadingServers(false); + return; + } + + setLoadingServers(true); + setUsingFallback(false); + let subscription: Subscription | null = null; + let foundUserServers = false; + + // Check existing event first + const event = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, ""); + if (event) { + const s = getServersFromEvent(event); + if (s.length > 0) { + setServers(s); + setSelectedServers(new Set(s)); // Select all by default + setLoadingServers(false); + foundUserServers = true; + } + } + + // Also fetch from network + subscription = addressLoader({ + kind: USER_SERVER_LIST_KIND, + pubkey, + identifier: "", + }).subscribe({ + next: () => { + const e = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, ""); + if (e) { + const s = getServersFromEvent(e); + if (s.length > 0) { + setServers(s); + setSelectedServers((prev) => (prev.size === 0 ? new Set(s) : prev)); + setUsingFallback(false); + foundUserServers = true; + } + } + setLoadingServers(false); + }, + error: () => setLoadingServers(false), + }); + + // After timeout, use fallbacks if no user servers found + const timeout = setTimeout(() => { + setLoadingServers(false); + if (!foundUserServers) { + applyFallbackServers(); + } + }, 3000); + + return () => { + subscription?.unsubscribe(); + clearTimeout(timeout); + }; + }, [open, pubkey, eventStore, applyFallbackServers]); + + // Create preview URL for selected file + useEffect(() => { + if (selectedFile && selectedFile.type.startsWith("image/")) { + const url = URL.createObjectURL(selectedFile); + setPreviewUrl(url); + return () => URL.revokeObjectURL(url); + } else { + setPreviewUrl(null); + } + }, [selectedFile]); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + setSelectedFile(files[0]); + setUploadResults([]); + setUploadErrors([]); + } + }, + [], + ); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const files = e.dataTransfer.files; + if (files && files.length > 0) { + setSelectedFile(files[0]); + setUploadResults([]); + setUploadErrors([]); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const toggleServer = useCallback((server: string) => { + setSelectedServers((prev) => { + const newSet = new Set(prev); + if (newSet.has(server)) { + newSet.delete(server); + } else { + newSet.add(server); + } + return newSet; + }); + }, []); + + const selectAll = useCallback( + () => setSelectedServers(new Set(servers)), + [servers], + ); + const selectNone = useCallback(() => setSelectedServers(new Set()), []); + + const handleUpload = async () => { + if (!selectedFile) { + toast.error("No file selected"); + return; + } + + if (selectedServers.size === 0) { + toast.error("Select at least one server"); + return; + } + + setUploading(true); + setUploadResults([]); + setUploadErrors([]); + + try { + const { results, errors } = await uploadBlobToServers( + selectedFile, + Array.from(selectedServers), + ); + + setUploadResults(results); + setUploadErrors(errors); + + if (results.length > 0) { + toast.success( + `Uploaded to ${results.length}/${selectedServers.size} servers`, + ); + // Call success callback with results + onSuccess(results); + } else { + const error = new Error("Upload failed on all servers"); + toast.error(error.message); + onError?.(error); + } + } catch (error) { + const err = error instanceof Error ? error : new Error("Upload failed"); + toast.error(err.message); + onError?.(err); + } finally { + setUploading(false); + } + }; + + const handleClose = () => { + if (!uploading) { + onCancel?.(); + onOpenChange(false); + } + }; + + // No account logged in + if (!pubkey) { + return ( + + + + Upload to Blossom + + Sign in to upload files to your Blossom servers. + + +
+ +

+ Account required to upload files +

+
+
+
+ ); + } + + return ( + + + + + + Upload to Blossom + + + Select a file and choose which servers to upload to. + + + +
+ {/* File Selection / Drop Zone */} +
fileInputRef.current?.click()} + > + + {selectedFile ? ( +
+ {previewUrl ? ( + Preview + ) : ( + getFileIcon( + selectedFile.type, + "size-12 text-muted-foreground", + ) + )} +

+ {selectedFile.name} +

+

+ {formatSize(selectedFile.size)} •{" "} + {selectedFile.type || "Unknown"} +

+
+ ) : ( +
+ +

+ Click or drop a file here +

+

+ Images, videos, or audio +

+
+ )} +
+ + {/* Server Selection */} + {loadingServers ? ( +
+ +
+ ) : ( +
+
+
+ {usingFallback ? ( + + ) : ( + + )} + + {usingFallback ? "Public Servers" : "Your Servers"} ( + {selectedServers.size}/{servers.length}) + +
+
+ + +
+
+ {usingFallback && ( +

+ No server list found. Using public servers. +

+ )} +
+ {servers.map((server) => ( + + ))} +
+
+ )} + + {/* Upload Results */} + {uploadResults.length > 0 && ( +
+
+ + + Uploaded ({uploadResults.length}) + +
+
+ {uploadResults.map((result) => ( + + {result.blob.url} + + ))} +
+
+ )} + + {/* Upload Errors */} + {uploadErrors.length > 0 && ( +
+
+ + + Failed ({uploadErrors.length}) + +
+
+ {uploadErrors.map((error) => ( +
+ {new URL(error.server).hostname}: {error.error} +
+ ))} +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +} + +/** + * Get icon for file type + */ +function getFileIcon(mimeType?: string, className = "size-4") { + if (!mimeType) return ; + if (mimeType.startsWith("image/")) return ; + if (mimeType.startsWith("video/")) return ; + if (mimeType.startsWith("audio/")) return ; + if (mimeType.startsWith("text/")) return ; + if (mimeType.includes("zip") || mimeType.includes("archive")) + return ; + return ; +} + +/** + * Format file size + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} diff --git a/src/components/BlossomViewer.tsx b/src/components/BlossomViewer.tsx new file mode 100644 index 0000000..1f17c40 --- /dev/null +++ b/src/components/BlossomViewer.tsx @@ -0,0 +1,1271 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { toast } from "sonner"; +import { + Server, + Upload, + List, + Copy, + CopyCheck, + Loader2, + CheckCircle, + XCircle, + ExternalLink, + Trash2, + RefreshCw, + HardDrive, + Clock, + FileIcon, + Image as ImageIcon, + Film, + Music, + FileText, + Archive, + ArrowLeft, + Eye, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useGrimoire } from "@/core/state"; +import { useEventStore } from "applesauce-react/hooks"; +import { addressLoader } from "@/services/loaders"; +import { + USER_SERVER_LIST_KIND, + getServersFromEvent, + checkServer, + listBlobs, + uploadBlobToServers, + deleteBlob, + type BlobDescriptor, + type ServerCheckResult, + type UploadResult, +} from "@/services/blossom"; +import { useCopy } from "@/hooks/useCopy"; +import type { BlossomSubcommand } from "@/lib/blossom-parser"; +import type { Subscription } from "rxjs"; +import { formatDistanceToNow } from "date-fns"; + +interface BlossomViewerProps { + subcommand: BlossomSubcommand; + serverUrl?: string; + pubkey?: string; + sourceUrl?: string; + targetServer?: string; + sha256?: string; +} + +/** + * BlossomViewer - Main component for Blossom blob management + */ +export function BlossomViewer({ + subcommand, + serverUrl, + pubkey, + sourceUrl, + targetServer, + sha256, +}: BlossomViewerProps) { + switch (subcommand) { + case "servers": + return ; + case "server": + return ; + case "upload": + return ; + case "list": + return ; + case "blob": + return ; + case "mirror": + return ; + case "delete": + return ; + default: + return ; + } +} + +/** + * ServersView - Display user's configured Blossom servers + */ +function ServersView() { + const { state } = useGrimoire(); + const eventStore = useEventStore(); + const pubkey = state.activeAccount?.pubkey; + const [servers, setServers] = useState([]); + const [serverStatus, setServerStatus] = useState< + Record + >({}); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + + // Fetch server list from kind 10063 + useEffect(() => { + if (!pubkey) { + setLoading(false); + return; + } + + let subscription: Subscription | null = null; + + const fetchServers = async () => { + // First check if we already have the event + const existingEvent = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + pubkey, + "", + ); + if (existingEvent) { + setServers(getServersFromEvent(existingEvent)); + setLoading(false); + } + + // Also fetch from network + subscription = addressLoader({ + kind: USER_SERVER_LIST_KIND, + pubkey, + identifier: "", + }).subscribe({ + next: () => { + const event = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + pubkey, + "", + ); + if (event) { + setServers(getServersFromEvent(event)); + } + setLoading(false); + }, + error: () => { + setLoading(false); + }, + }); + }; + + fetchServers(); + + // Timeout fallback + const timeout = setTimeout(() => { + setLoading(false); + }, 5000); + + return () => { + subscription?.unsubscribe(); + clearTimeout(timeout); + }; + }, [pubkey, eventStore]); + + // Check all servers + const checkAllServers = useCallback(async () => { + if (servers.length === 0) return; + + setChecking(true); + const results: Record = {}; + + await Promise.all( + servers.map(async (url) => { + const result = await checkServer(url); + results[url] = result; + }), + ); + + setServerStatus(results); + setChecking(false); + }, [servers]); + + // Auto-check servers when loaded + useEffect(() => { + if (servers.length > 0 && Object.keys(serverStatus).length === 0) { + checkAllServers(); + } + }, [servers, serverStatus, checkAllServers]); + + if (!pubkey) { + return ( +
+ +

Account Required

+

+ Log in to view your Blossom server list. Your servers are stored in a + kind 10063 event. +

+
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + Your Blossom Servers ({servers.length}) + +
+ +
+ + {/* Server List */} +
+ {servers.length === 0 ? ( +
+ +

No Servers Configured

+

+ You haven't published a Blossom server list (kind 10063) yet. + Configure your servers in a Nostr client that supports Blossom. +

+
+ ) : ( +
+ {servers.map((url) => ( + + ))} +
+ )} +
+
+ ); +} + +/** + * ServerRow - Single server display with status + */ +function ServerRow({ + url, + status, +}: { + url: string; + status?: ServerCheckResult; +}) { + const { copy, copied } = useCopy(); + + return ( +
+
+ {status ? ( + status.online ? ( + + ) : ( + + ) + ) : ( +
+ )} +
+
{url}
+ {status && ( +
+ {status.online ? ( + + Online ({status.responseTime}ms) + + ) : ( + {status.error} + )} +
+ )} +
+
+
+ + +
+
+ ); +} + +/** + * ServerView - View info about a specific Blossom server + */ +function ServerView({ serverUrl }: { serverUrl: string }) { + const { copy, copied } = useCopy(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + // Check server status on mount + useEffect(() => { + let cancelled = false; + + const check = async () => { + setLoading(true); + const result = await checkServer(serverUrl); + if (!cancelled) { + setStatus(result); + setLoading(false); + } + }; + + check(); + + return () => { + cancelled = true; + }; + }, [serverUrl]); + + const hostname = (() => { + try { + return new URL(serverUrl).hostname; + } catch { + return serverUrl; + } + })(); + + return ( +
+ {/* Header */} +
+ + Blossom Server +
+ +
+ {/* Server Info */} +
+
+
+ URL +
+
+ {serverUrl} + + +
+
+ +
+
+ Hostname +
+
{hostname}
+
+ +
+
+ Status +
+ {loading ? ( +
+ + + Checking... + +
+ ) : status ? ( +
+ {status.online ? ( + <> + + + Online ({status.responseTime}ms) + + + ) : ( + <> + + {status.error} + + )} +
+ ) : null} +
+
+ + {/* Actions */} +
+ +
+
+
+ ); +} + +/** + * UploadView - File upload interface with server selection + */ +function UploadView() { + const { state } = useGrimoire(); + const eventStore = useEventStore(); + const pubkey = state.activeAccount?.pubkey; + const [servers, setServers] = useState([]); + const [selectedServers, setSelectedServers] = useState>( + new Set(), + ); + const [uploading, setUploading] = useState(false); + const [results, setResults] = useState([]); + const [errors, setErrors] = useState<{ server: string; error: string }[]>([]); + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = useRef(null); + const { copy, copied } = useCopy(); + + // Fetch servers + useEffect(() => { + if (!pubkey) return; + + const event = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, ""); + if (event) { + const s = getServersFromEvent(event); + setServers(s); + // Select all by default + setSelectedServers(new Set(s)); + } + + const subscription = addressLoader({ + kind: USER_SERVER_LIST_KIND, + pubkey, + identifier: "", + }).subscribe({ + next: () => { + const e = eventStore.getReplaceable(USER_SERVER_LIST_KIND, pubkey, ""); + if (e) { + const s = getServersFromEvent(e); + setServers(s); + // Select all by default if not already set + setSelectedServers((prev) => (prev.size === 0 ? new Set(s) : prev)); + } + }, + }); + + return () => subscription.unsubscribe(); + }, [pubkey, eventStore]); + + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + setSelectedFile(files[0]); + setResults([]); + setErrors([]); + } + }; + + const toggleServer = (server: string) => { + setSelectedServers((prev) => { + const newSet = new Set(prev); + if (newSet.has(server)) { + newSet.delete(server); + } else { + newSet.add(server); + } + return newSet; + }); + }; + + const selectAll = () => setSelectedServers(new Set(servers)); + const selectNone = () => setSelectedServers(new Set()); + + const handleUpload = async () => { + if (!selectedFile) { + toast.error("No file selected"); + return; + } + + if (selectedServers.size === 0) { + toast.error("Select at least one server"); + return; + } + + setUploading(true); + setResults([]); + setErrors([]); + + try { + const { results: uploadResults, errors: uploadErrors } = + await uploadBlobToServers(selectedFile, Array.from(selectedServers)); + + setResults(uploadResults); + setErrors(uploadErrors); + + if (uploadResults.length > 0) { + toast.success( + `Uploaded to ${uploadResults.length}/${selectedServers.size} servers`, + ); + } else { + toast.error("Upload failed on all servers"); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Upload failed"); + } finally { + setUploading(false); + } + }; + + if (!pubkey) { + return ( +
+ +

Account Required

+

+ Log in to upload files to your Blossom servers. +

+
+ ); + } + + return ( +
+ {/* Header */} +
+ + Upload to Blossom +
+ +
+ {/* File Selection */} +
+ + {selectedFile ? ( +
+ {getFileIcon(selectedFile.type, "size-8")} +

+ {selectedFile.name} +

+

+ {formatSize(selectedFile.size)} -{" "} + {selectedFile.type || "Unknown type"} +

+ +
+ ) : ( +
+ +

Select a file to upload

+ +
+ )} +
+ + {/* Server Selection */} + {servers.length > 0 && ( +
+
+

+ Upload to ({selectedServers.size}/{servers.length} selected) +

+
+ + +
+
+
+ {servers.map((server) => ( + + ))} +
+
+ )} + + {servers.length === 0 && ( +
+ +

No Blossom servers configured

+
+ )} + + {/* Upload Button */} + + + {/* Results */} + {results.length > 0 && ( +
+

+ Uploaded Successfully ({results.length}) +

+ {results.map((result) => ( +
+
+ {result.server} + +
+ + {result.blob.url} + +
+ ))} +
+ )} + + {/* Errors */} + {errors.length > 0 && ( +
+

+ Failed ({errors.length}) +

+ {errors.map((error) => ( +
+ {error.server} +

{error.error}

+
+ ))} +
+ )} +
+
+ ); +} + +/** + * Get icon for file type + */ +function getFileIcon(mimeType?: string, className = "size-4") { + if (!mimeType) return ; + if (mimeType.startsWith("image/")) return ; + if (mimeType.startsWith("video/")) return ; + if (mimeType.startsWith("audio/")) return ; + if (mimeType.startsWith("text/")) return ; + if (mimeType.includes("zip") || mimeType.includes("archive")) + return ; + return ; +} + +/** + * Format file size + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * ListBlobsView - List blobs for a user + */ +function ListBlobsView({ pubkey }: { pubkey?: string }) { + const { state } = useGrimoire(); + const eventStore = useEventStore(); + const accountPubkey = state.activeAccount?.pubkey; + const targetPubkey = pubkey || accountPubkey; + + const [servers, setServers] = useState([]); + const [blobs, setBlobs] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedServer, setSelectedServer] = useState(null); + const [selectedBlob, setSelectedBlob] = useState(null); + + // Fetch servers for the target pubkey + useEffect(() => { + if (!targetPubkey) { + setLoading(false); + return; + } + + const event = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + targetPubkey, + "", + ); + if (event) { + const s = getServersFromEvent(event); + setServers(s); + if (s.length > 0 && !selectedServer) { + setSelectedServer(s[0]); + } + } + + const subscription = addressLoader({ + kind: USER_SERVER_LIST_KIND, + pubkey: targetPubkey, + identifier: "", + }).subscribe({ + next: () => { + const e = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + targetPubkey, + "", + ); + if (e) { + const s = getServersFromEvent(e); + setServers(s); + if (s.length > 0 && !selectedServer) { + setSelectedServer(s[0]); + } + } + setLoading(false); + }, + error: () => setLoading(false), + }); + + const timeout = setTimeout(() => setLoading(false), 5000); + + return () => { + subscription.unsubscribe(); + clearTimeout(timeout); + }; + }, [targetPubkey, eventStore, selectedServer]); + + // Fetch blobs when server is selected + useEffect(() => { + if (!selectedServer || !targetPubkey) return; + + const fetchBlobs = async () => { + setLoading(true); + try { + const result = await listBlobs(selectedServer, targetPubkey); + setBlobs(result); + } catch (_error) { + toast.error("Failed to list blobs"); + setBlobs([]); + } finally { + setLoading(false); + } + }; + + fetchBlobs(); + }, [selectedServer, targetPubkey]); + + // Show blob detail view if a blob is selected + if (selectedBlob) { + return ( + setSelectedBlob(null)} + /> + ); + } + + if (!targetPubkey) { + return ( +
+ +

Account Required

+

+ Log in to list your blobs, or specify a pubkey. +

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + Blobs ({blobs.length}) +
+ {servers.length > 1 && ( + + )} +
+ + {/* Blob List */} +
+ {loading ? ( +
+ +
+ ) : servers.length === 0 ? ( +
+ +

No Servers Found

+

+ This user has no Blossom server list configured. +

+
+ ) : blobs.length === 0 ? ( +
+ +

No Blobs Found

+

+ No files uploaded to this server yet. +

+
+ ) : ( +
+ {blobs.map((blob) => ( + setSelectedBlob(blob)} + /> + ))} +
+ )} +
+
+ ); +} + +/** + * BlobRow - Single blob in list view + */ +function BlobRow({ + blob, + onClick, +}: { + blob: BlobDescriptor; + onClick: () => void; +}) { + const { copy, copied } = useCopy(); + + return ( +
+
+ {getFileIcon(blob.type)} +
+
+ {blob.sha256.slice(0, 16)}... +
+
+ {formatSize(blob.size)} + {blob.type && {blob.type}} + {blob.uploaded && ( + + + {formatDistanceToNow(blob.uploaded * 1000, { + addSuffix: true, + })} + + )} +
+
+
+
+ + +
+
+ ); +} + +/** + * BlobDetailView - Detailed view of a single blob + */ +function BlobDetailView({ + sha256, + serverUrl, + blob: initialBlob, + onBack, +}: { + sha256?: string; + serverUrl?: string; + blob?: BlobDescriptor; + onBack?: () => void; +}) { + const { copy, copied } = useCopy(); + const blob = initialBlob; + + // If we have a blob descriptor, use that data + const blobUrl = + blob?.url || (serverUrl && sha256 ? `${serverUrl}/${sha256}` : null); + const blobSha256 = blob?.sha256 || sha256; + const mimeType = blob?.type; + + const isImage = mimeType?.startsWith("image/"); + const isVideo = mimeType?.startsWith("video/"); + const isAudio = mimeType?.startsWith("audio/"); + + if (!blobSha256) { + return ( +
+ +

No Blob Selected

+
+ ); + } + + return ( +
+ {/* Header */} +
+ {onBack && ( + + )} + + Blob Details +
+ +
+ {/* Preview */} + {blobUrl && ( +
+ {isImage && ( + Blob preview + )} + {isVideo && ( +
+ )} + + {/* Info */} +
+
+
+ SHA256 +
+
+ {blobSha256} + +
+
+ + {blobUrl && ( +
+
+ URL +
+
+ {blobUrl} + + +
+
+ )} + + {serverUrl && ( +
+
+ Server +
+
+ + {serverUrl} +
+
+ )} + + {blob && ( + <> +
+
+ Size +
+
{formatSize(blob.size)}
+
+ + {blob.type && ( +
+
+ Type +
+
{blob.type}
+
+ )} + + {blob.uploaded && ( +
+
+ Uploaded +
+
+ {new Date(blob.uploaded * 1000).toLocaleString()} + + ( + {formatDistanceToNow(blob.uploaded * 1000, { + addSuffix: true, + })} + ) + +
+
+ )} + + )} +
+
+
+ ); +} + +/** + * MirrorView - Mirror a blob to another server + */ +function MirrorView({ + sourceUrl, + targetServer, +}: { + sourceUrl: string; + targetServer: string; +}) { + return ( +
+ +

Mirror Blob

+

+ Mirror from: +
+ {sourceUrl} +
+
+ To server: +
+ {targetServer} +

+

+ (Mirror functionality coming soon) +

+
+ ); +} + +/** + * DeleteView - Delete a blob from a server + */ +function DeleteView({ + sha256, + serverUrl, +}: { + sha256: string; + serverUrl: string; +}) { + const [deleting, setDeleting] = useState(false); + const [deleted, setDeleted] = useState(false); + const [error, setError] = useState(null); + + const handleDelete = async () => { + setDeleting(true); + setError(null); + try { + await deleteBlob(serverUrl, sha256); + setDeleted(true); + toast.success("Blob deleted"); + } catch (err) { + setError(err instanceof Error ? err.message : "Delete failed"); + toast.error("Delete failed"); + } finally { + setDeleting(false); + } + }; + + return ( +
+ {deleted ? ( + <> + +

Blob Deleted

+ + ) : ( + <> + +

Delete Blob

+
+

SHA256:

+ {sha256} +

From server:

+ {serverUrl} +
+ {error &&

{error}

} + + + )} +
+ ); +} + +export default BlossomViewer; diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index d7e9e7c..60eb711 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -2,7 +2,14 @@ import { useMemo, useState, memo, useCallback, useRef, useEffect } from "react"; import { use$ } from "applesauce-react/hooks"; import { from, catchError, of, map } from "rxjs"; import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; -import { Loader2, Reply, Zap, AlertTriangle, RefreshCw } from "lucide-react"; +import { + Loader2, + Reply, + Zap, + AlertTriangle, + RefreshCw, + Paperclip, +} from "lucide-react"; import { getZapRequest } from "applesauce-common/helpers/zap"; import { toast } from "sonner"; import accountManager from "@/services/accounts"; @@ -33,6 +40,7 @@ import { MentionEditor, type MentionEditorHandle, type EmojiTag, + type BlobAttachment, } from "./editor/MentionEditor"; import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; @@ -43,6 +51,7 @@ import { TooltipProvider, TooltipTrigger, } from "./ui/tooltip"; +import { useBlossomUpload } from "@/hooks/useBlossomUpload"; interface ChatViewerProps { protocol: ChatProtocol; @@ -336,6 +345,28 @@ export function ChatViewer({ // Emoji search for custom emoji autocomplete const { searchEmojis } = useEmojiSearch(); + // Ref to MentionEditor for programmatic submission + const editorRef = useRef(null); + + // Blossom upload hook for file attachments + const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ + accept: "image/*,video/*,audio/*", + onSuccess: (results) => { + if (results.length > 0 && editorRef.current) { + // Insert the first successful upload as a blob attachment with metadata + const { blob, server } = results[0]; + editorRef.current.insertBlob({ + url: blob.url, + sha256: blob.sha256, + mimeType: blob.type, + size: blob.size, + server, + }); + editorRef.current.focus(); + } + }, + }); + // Get the appropriate adapter for this protocol const adapter = useMemo(() => getAdapter(protocol), [protocol]); @@ -447,9 +478,6 @@ export function ChatViewer({ // Ref to Virtuoso for programmatic scrolling const virtuosoRef = useRef(null); - // Ref to MentionEditor for programmatic submission - const editorRef = useRef(null); - // State for send in progress (prevents double-sends) const [isSending, setIsSending] = useState(false); @@ -458,6 +486,7 @@ export function ChatViewer({ content: string, replyToId?: string, emojiTags?: EmojiTag[], + blobAttachments?: BlobAttachment[], ) => { if (!conversation || !hasActiveAccount || isSending) return; @@ -495,6 +524,7 @@ export function ChatViewer({ await adapter.sendMessage(conversation, content, { replyTo: replyToId, emojiTags, + blobAttachments, }); setReplyTo(undefined); // Clear reply context only on success } catch (error) { @@ -851,6 +881,25 @@ export function ChatViewer({ /> )}
+ + + + + + +

Attach media

+
+
+
{ + onSubmit={(content, emojiTags, blobAttachments) => { if (content.trim()) { - handleSend(content, replyTo, emojiTags); + handleSend(content, replyTo, emojiTags, blobAttachments); } }} className="flex-1 min-w-0" @@ -878,6 +927,7 @@ export function ChatViewer({ {isSending ? : "Send"}
+ {uploadDialog}
) : (
diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index 67ae845..667fce3 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -9,6 +9,8 @@ import { Inbox, Send, Wifi, + HardDrive, + ExternalLink, } from "lucide-react"; import { kinds, nip19 } from "nostr-tools"; import { useEventStore, use$ } from "applesauce-react/hooks"; @@ -27,9 +29,10 @@ import { useRelayState } from "@/hooks/useRelayState"; import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils"; import { addressLoader } from "@/services/loaders"; import { relayListCache } from "@/services/relay-list-cache"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import type { Subscription } from "rxjs"; import { useGrimoire } from "@/core/state"; +import { USER_SERVER_LIST_KIND, getServersFromEvent } from "@/services/blossom"; export interface ProfileViewerProps { pubkey: string; @@ -121,6 +124,48 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) { [eventStore, resolvedPubkey], ); + // Blossom servers state (kind 10063) + const [blossomServers, setBlossomServers] = useState([]); + + // Fetch Blossom server list (kind 10063) + useEffect(() => { + if (!resolvedPubkey) return; + + let subscription: Subscription | null = null; + + // Check if we already have the event in store + const existingEvent = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + resolvedPubkey, + "", + ); + if (existingEvent) { + setBlossomServers(getServersFromEvent(existingEvent)); + } + + // Also fetch from network + subscription = addressLoader({ + kind: USER_SERVER_LIST_KIND, + pubkey: resolvedPubkey, + identifier: "", + }).subscribe({ + next: () => { + const event = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + resolvedPubkey, + "", + ); + if (event) { + setBlossomServers(getServersFromEvent(event)); + } + }, + }); + + return () => { + subscription?.unsubscribe(); + }; + }, [resolvedPubkey, eventStore]); + // Combine all relays (inbox + outbox) for nprofile const allRelays = [...new Set([...inboxRelays, ...outboxRelays])]; @@ -274,6 +319,36 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) { )} + + {/* Blossom servers dropdown */} + {blossomServers.length > 0 && ( + + + + + + {blossomServers.map((url) => ( + window.open(url, "_blank")} + > +
+ + {url} +
+ +
+ ))} +
+
+ )}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 2f0dfd1..84d0ffa 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -39,6 +39,9 @@ const SpellsViewer = lazy(() => const SpellbooksViewer = lazy(() => import("./SpellbooksViewer").then((m) => ({ default: m.SpellbooksViewer })), ); +const BlossomViewer = lazy(() => + import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), +); // Loading fallback component function ViewerLoading() { @@ -195,6 +198,18 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { case "spellbooks": content = ; break; + case "blossom": + content = ( + + ); + break; default: content = (
diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index a7f6a3b..d301565 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -7,7 +7,7 @@ import { useRef, } from "react"; import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; -import { Extension } from "@tiptap/core"; +import { Extension, Node, mergeAttributes } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import Mention from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; @@ -40,6 +40,22 @@ export interface EmojiTag { url: string; } +/** + * Represents a blob attachment for imeta tags (NIP-92) + */ +export interface BlobAttachment { + /** The URL of the blob */ + url: string; + /** SHA256 hash of the blob content */ + sha256: string; + /** MIME type of the blob */ + mimeType?: string; + /** Size in bytes */ + size?: number; + /** Blossom server URL */ + server?: string; +} + /** * Result of serializing editor content */ @@ -48,11 +64,17 @@ export interface SerializedContent { text: string; /** Emoji tags to include in the event (NIP-30) */ emojiTags: EmojiTag[]; + /** Blob attachments for imeta tags (NIP-92) */ + blobAttachments: BlobAttachment[]; } export interface MentionEditorProps { placeholder?: string; - onSubmit?: (content: string, emojiTags: EmojiTag[]) => void; + onSubmit?: ( + content: string, + emojiTags: EmojiTag[], + blobAttachments: BlobAttachment[], + ) => void; searchProfiles: (query: string) => Promise; searchEmojis?: (query: string) => Promise; searchCommands?: (query: string) => Promise; @@ -68,6 +90,10 @@ export interface MentionEditorHandle { getSerializedContent: () => SerializedContent; isEmpty: () => boolean; submit: () => void; + /** Insert text at the current cursor position */ + insertText: (text: string) => void; + /** Insert a blob attachment with rich preview */ + insertBlob: (blob: BlobAttachment) => void; } // Create emoji extension by extending Mention with a different name and custom node view @@ -149,6 +175,107 @@ const EmojiMention = Mention.extend({ }, }); +// Create blob attachment extension for media previews +const BlobAttachmentNode = Node.create({ + name: "blobAttachment", + group: "inline", + inline: true, + atom: true, + + addAttributes() { + return { + url: { default: null }, + sha256: { default: null }, + mimeType: { default: null }, + size: { default: null }, + server: { default: null }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span[data-blob-attachment="true"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }), + ]; + }, + + addNodeView() { + return ({ node }) => { + const { url, mimeType, size } = node.attrs; + + // Create wrapper span + const dom = document.createElement("span"); + dom.className = + "blob-attachment inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 border border-border text-xs align-middle"; + dom.contentEditable = "false"; + + const isImage = mimeType?.startsWith("image/"); + const isVideo = mimeType?.startsWith("video/"); + const isAudio = mimeType?.startsWith("audio/"); + + if (isImage && url) { + // Show image thumbnail + const img = document.createElement("img"); + img.src = url; + img.alt = "attachment"; + img.className = "h-4 w-4 object-cover rounded"; + img.draggable = false; + dom.appendChild(img); + } else { + // Show icon based on type + const icon = document.createElement("span"); + icon.className = "text-muted-foreground"; + if (isVideo) { + icon.textContent = "🎬"; + } else if (isAudio) { + icon.textContent = "🎵"; + } else { + icon.textContent = "📎"; + } + dom.appendChild(icon); + } + + // Add type label + const label = document.createElement("span"); + label.className = "text-muted-foreground truncate max-w-[80px]"; + if (isImage) { + label.textContent = "image"; + } else if (isVideo) { + label.textContent = "video"; + } else if (isAudio) { + label.textContent = "audio"; + } else { + label.textContent = "file"; + } + dom.appendChild(label); + + // Add size if available + if (size) { + const sizeEl = document.createElement("span"); + sizeEl.className = "text-muted-foreground/70"; + sizeEl.textContent = formatBlobSize(size); + dom.appendChild(sizeEl); + } + + return { dom }; + }; + }, +}); + +function formatBlobSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + export const MentionEditor = forwardRef< MentionEditorHandle, MentionEditorProps @@ -442,12 +569,14 @@ export const MentionEditor = forwardRef< [searchCommands], ); - // Helper function to serialize editor content with mentions and emojis + // Helper function to serialize editor content with mentions, emojis, and blobs const serializeContent = useCallback( (editorInstance: any): SerializedContent => { let text = ""; const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; const seenEmojis = new Set(); + const seenBlobs = new Set(); const json = editorInstance.getJSON(); json.content?.forEach((node: any) => { @@ -483,6 +612,23 @@ export const MentionEditor = forwardRef< emojiTags.push({ shortcode, url }); } } + } else if (child.type === "blobAttachment") { + // Blob attachment - output URL and track for imeta tag + const { url, sha256, mimeType, size, server } = child.attrs; + if (url) { + text += url; + // Add to blob attachments for imeta tags (dedupe by sha256) + if (sha256 && !seenBlobs.has(sha256)) { + seenBlobs.add(sha256); + blobAttachments.push({ + url, + sha256, + mimeType: mimeType || undefined, + size: size || undefined, + server: server || undefined, + }); + } + } } }); text += "\n"; @@ -492,6 +638,7 @@ export const MentionEditor = forwardRef< return { text: text.trim(), emojiTags, + blobAttachments, }; }, [], @@ -502,9 +649,10 @@ export const MentionEditor = forwardRef< (editorInstance: any) => { if (!editorInstance || !onSubmit) return; - const { text, emojiTags } = serializeContent(editorInstance); + const { text, emojiTags, blobAttachments } = + serializeContent(editorInstance); if (text) { - onSubmit(text, emojiTags); + onSubmit(text, emojiTags, blobAttachments); editorInstance.commands.clearContent(); } }, @@ -574,6 +722,8 @@ export const MentionEditor = forwardRef< Placeholder.configure({ placeholder, }), + // Add blob attachment extension for media previews + BlobAttachmentNode, ]; // Add emoji extension if search is provided @@ -673,7 +823,7 @@ export const MentionEditor = forwardRef< clear: () => editor?.commands.clearContent(), getContent: () => editor?.getText() || "", getSerializedContent: () => { - if (!editor) return { text: "", emojiTags: [] }; + if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; return serializeContent(editor); }, isEmpty: () => editor?.isEmpty ?? true, @@ -682,6 +832,32 @@ export const MentionEditor = forwardRef< handleSubmit(editor); } }, + insertText: (text: string) => { + if (editor) { + editor.chain().focus().insertContent(text).run(); + } + }, + insertBlob: (blob: BlobAttachment) => { + if (editor) { + editor + .chain() + .focus() + .insertContent([ + { + type: "blobAttachment", + attrs: { + url: blob.url, + sha256: blob.sha256, + mimeType: blob.mimeType, + size: blob.size, + server: blob.server, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + } + }, }), [editor, serializeContent, handleSubmit], ); diff --git a/src/components/nostr/kinds/BlossomServerListRenderer.tsx b/src/components/nostr/kinds/BlossomServerListRenderer.tsx new file mode 100644 index 0000000..ddfb84e --- /dev/null +++ b/src/components/nostr/kinds/BlossomServerListRenderer.tsx @@ -0,0 +1,127 @@ +import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer"; +import { NostrEvent } from "@/types/nostr"; +import { getServersFromEvent } from "@/services/blossom"; +import { useGrimoire } from "@/core/state"; +import { HardDrive, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +/** + * Kind 10063 Renderer - Blossom User Server List (Feed View) + * Shows the user's configured Blossom blob storage servers + */ +export function BlossomServerListRenderer({ event }: BaseEventProps) { + const { addWindow } = useGrimoire(); + const servers = getServersFromEvent(event); + + const handleServerClick = (serverUrl: string) => { + // Open the blossom viewer with specific server info + addWindow( + "blossom", + { subcommand: "server", serverUrl }, + `blossom server ${serverUrl}`, + undefined, + ); + }; + + if (servers.length === 0) { + return ( + +
+ No Blossom servers configured +
+
+ ); + } + + return ( + +
+ {servers.map((url) => ( +
handleServerClick(url)} + > + + + {url} + + +
+ ))} +
+
+ ); +} + +/** + * Kind 10063 Detail Renderer - Blossom User Server List (Detail View) + * Shows full Blossom server list with clickable links + */ +export function BlossomServerListDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { + const { addWindow } = useGrimoire(); + const servers = getServersFromEvent(event); + + const handleServerClick = (serverUrl: string) => { + addWindow( + "blossom", + { subcommand: "server", serverUrl }, + `blossom server ${serverUrl}`, + undefined, + ); + }; + + if (servers.length === 0) { + return ( +
+ No Blossom servers configured +
+ ); + } + + return ( +
+
+ + Blossom Servers ({servers.length}) +
+ {servers.map((url) => ( +
handleServerClick(url)} + > + + + {url} + + +
+ ))} +
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 269c71c..0494f88 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -26,6 +26,10 @@ import { Kind9802Renderer } from "./HighlightRenderer"; import { Kind9802DetailRenderer } from "./HighlightDetailRenderer"; import { Kind10002Renderer } from "./RelayListRenderer"; import { Kind10002DetailRenderer } from "./RelayListDetailRenderer"; +import { + BlossomServerListRenderer, + BlossomServerListDetailRenderer, +} from "./BlossomServerListRenderer"; import { Kind10317Renderer } from "./GraspListRenderer"; import { Kind10317DetailRenderer } from "./GraspListDetailRenderer"; import { Kind30023Renderer } from "./ArticleRenderer"; @@ -170,6 +174,7 @@ const kindRenderers: Record> = { 10020: MediaFollowListRenderer, // Media Follow List (NIP-51) 10030: EmojiListRenderer, // User Emoji List (NIP-51) 10050: GenericRelayListRenderer, // DM Relay List (NIP-51) + 10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03) 10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51) 10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51) 10317: Kind10317Renderer, // User Grasp List (NIP-34) @@ -259,6 +264,7 @@ const detailRenderers: Record< 10015: InterestListDetailRenderer, // Interest List Detail (NIP-51) 10020: MediaFollowListDetailRenderer, // Media Follow List Detail (NIP-51) 10030: EmojiListDetailRenderer, // User Emoji List Detail (NIP-51) + 10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03) 10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51) 10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51) 10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34) diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index fd62988..462cc1a 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -27,6 +27,7 @@ import { GitMerge, GitPullRequest, BookHeart, + HardDrive, Hash, Heart, Highlighter, @@ -828,13 +829,13 @@ export const EVENT_KINDS: Record = { // nip: "Marmot", // icon: Key, // }, - // 10063: { - // kind: 10063, - // name: "User Server List", - // description: "User server list", - // nip: "Blossom", - // icon: Server, - // }, + 10063: { + kind: 10063, + name: "Blossom Server List", + description: "User's Blossom blob storage servers", + nip: "BUD-03", + icon: HardDrive, + }, 10096: { kind: 10096, name: "File Storage", diff --git a/src/hooks/useBlossomUpload.tsx b/src/hooks/useBlossomUpload.tsx new file mode 100644 index 0000000..aee53a5 --- /dev/null +++ b/src/hooks/useBlossomUpload.tsx @@ -0,0 +1,93 @@ +import { useState, useCallback, useMemo } from "react"; +import { BlossomUploadDialog } from "@/components/BlossomUploadDialog"; +import type { UploadResult } from "@/services/blossom"; + +export interface UseBlossomUploadOptions { + /** Called when upload completes successfully */ + onSuccess?: (results: UploadResult[]) => void; + /** Called when upload is cancelled */ + onCancel?: () => void; + /** Called when upload fails */ + onError?: (error: Error) => void; + /** File types to accept (e.g., "image/*,video/*,audio/*") */ + accept?: string; +} + +export interface UseBlossomUploadReturn { + /** Open the upload dialog */ + open: () => void; + /** Close the upload dialog */ + close: () => void; + /** Whether the dialog is currently open */ + isOpen: boolean; + /** The dialog component to render */ + dialog: React.ReactNode; +} + +/** + * Hook for managing Blossom file uploads with a dialog + * + * @example + * ```tsx + * function MyComponent() { + * const { open, dialog } = useBlossomUpload({ + * onSuccess: (results) => { + * const url = results[0].blob.url; + * insertIntoEditor(url); + * } + * }); + * + * return ( + * <> + * + * {dialog} + * + * ); + * } + * ``` + */ +export function useBlossomUpload( + options: UseBlossomUploadOptions = {}, +): UseBlossomUploadReturn { + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + + const handleSuccess = useCallback( + (results: UploadResult[]) => { + options.onSuccess?.(results); + close(); + }, + [options.onSuccess, close], + ); + + const handleCancel = useCallback(() => { + options.onCancel?.(); + close(); + }, [options.onCancel, close]); + + const handleError = useCallback( + (error: Error) => { + options.onError?.(error); + // Don't close on error - let user retry + }, + [options.onError], + ); + + const dialog = useMemo( + () => ( + + ), + [isOpen, handleSuccess, handleCancel, handleError, options.accept], + ); + + return { open, close, isOpen, dialog }; +} diff --git a/src/lib/blossom-parser.test.ts b/src/lib/blossom-parser.test.ts new file mode 100644 index 0000000..f0e77dd --- /dev/null +++ b/src/lib/blossom-parser.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { parseBlossomCommand } from "./blossom-parser"; + +// Mock NIP-05 resolution +vi.mock("./nip05", () => ({ + isNip05: (input: string) => + input.includes("@") || /^[a-z0-9-]+\.[a-z]{2,}$/i.test(input), + resolveNip05: vi.fn(), +})); + +import { resolveNip05 } from "./nip05"; +const mockResolveNip05 = vi.mocked(resolveNip05); + +describe("parseBlossomCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("servers subcommand", () => { + it("should default to servers when no args provided", async () => { + const result = await parseBlossomCommand([]); + expect(result.subcommand).toBe("servers"); + }); + + it("should parse explicit servers subcommand", async () => { + const result = await parseBlossomCommand(["servers"]); + expect(result.subcommand).toBe("servers"); + }); + }); + + describe("server subcommand", () => { + it("should parse server with URL", async () => { + const result = await parseBlossomCommand([ + "server", + "https://blossom.primal.net", + ]); + expect(result.subcommand).toBe("server"); + expect(result.serverUrl).toBe("https://blossom.primal.net"); + }); + + it("should normalize server URL without protocol", async () => { + const result = await parseBlossomCommand([ + "server", + "blossom.primal.net", + ]); + expect(result.serverUrl).toBe("https://blossom.primal.net"); + }); + + it("should preserve http:// protocol", async () => { + const result = await parseBlossomCommand([ + "server", + "http://localhost:3000", + ]); + expect(result.serverUrl).toBe("http://localhost:3000"); + }); + + it("should throw error when URL missing", async () => { + await expect(parseBlossomCommand(["server"])).rejects.toThrow( + "Server URL required", + ); + }); + }); + + describe("upload subcommand", () => { + it("should parse upload subcommand", async () => { + const result = await parseBlossomCommand(["upload"]); + expect(result.subcommand).toBe("upload"); + }); + }); + + describe("list subcommand", () => { + const testPubkey = + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + + it("should parse list with no args (uses active account)", async () => { + const result = await parseBlossomCommand(["list"], testPubkey); + expect(result.subcommand).toBe("list"); + expect(result.pubkey).toBe(testPubkey); + }); + + it("should parse list alias 'ls'", async () => { + const result = await parseBlossomCommand(["ls"], testPubkey); + expect(result.subcommand).toBe("list"); + }); + + it("should parse list with hex pubkey", async () => { + const result = await parseBlossomCommand(["list", testPubkey]); + expect(result.pubkey).toBe(testPubkey); + }); + + it("should parse list with npub", async () => { + const npub = + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const result = await parseBlossomCommand(["list", npub]); + expect(result.pubkey).toBe(testPubkey); + }); + + it("should parse list with $me alias", async () => { + const result = await parseBlossomCommand(["list", "$me"], testPubkey); + expect(result.pubkey).toBe(testPubkey); + }); + + it("should parse list with NIP-05 identifier", async () => { + const resolvedPubkey = + "0000000000000000000000000000000000000000000000000000000000000001"; + mockResolveNip05.mockResolvedValueOnce(resolvedPubkey); + + const result = await parseBlossomCommand(["list", "fiatjaf@fiatjaf.com"]); + expect(mockResolveNip05).toHaveBeenCalledWith("fiatjaf@fiatjaf.com"); + expect(result.pubkey).toBe(resolvedPubkey); + }); + + it("should parse list with bare domain NIP-05", async () => { + const resolvedPubkey = + "0000000000000000000000000000000000000000000000000000000000000001"; + mockResolveNip05.mockResolvedValueOnce(resolvedPubkey); + + const result = await parseBlossomCommand(["list", "fiatjaf.com"]); + expect(mockResolveNip05).toHaveBeenCalledWith("fiatjaf.com"); + expect(result.pubkey).toBe(resolvedPubkey); + }); + + it("should throw error for invalid pubkey format", async () => { + mockResolveNip05.mockResolvedValueOnce(null); + + await expect(parseBlossomCommand(["list", "invalid"])).rejects.toThrow( + "Invalid pubkey format", + ); + }); + }); + + describe("blob subcommand", () => { + const validSha256 = + "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"; + + it("should parse blob with sha256", async () => { + const result = await parseBlossomCommand(["blob", validSha256]); + expect(result.subcommand).toBe("blob"); + expect(result.sha256).toBe(validSha256); + expect(result.serverUrl).toBeUndefined(); + }); + + it("should parse blob alias 'view'", async () => { + const result = await parseBlossomCommand(["view", validSha256]); + expect(result.subcommand).toBe("blob"); + }); + + it("should parse blob with server URL", async () => { + const result = await parseBlossomCommand([ + "blob", + validSha256, + "blossom.primal.net", + ]); + expect(result.sha256).toBe(validSha256); + expect(result.serverUrl).toBe("https://blossom.primal.net"); + }); + + it("should lowercase sha256", async () => { + const upperSha256 = validSha256.toUpperCase(); + const result = await parseBlossomCommand(["blob", upperSha256]); + expect(result.sha256).toBe(validSha256); + }); + + it("should throw error when sha256 missing", async () => { + await expect(parseBlossomCommand(["blob"])).rejects.toThrow( + "SHA256 hash required", + ); + }); + + it("should throw error for invalid sha256", async () => { + await expect(parseBlossomCommand(["blob", "invalid"])).rejects.toThrow( + "Invalid SHA256 hash", + ); + }); + + it("should throw error for sha256 with wrong length", async () => { + await expect(parseBlossomCommand(["blob", "abc123"])).rejects.toThrow( + "Invalid SHA256 hash", + ); + }); + }); + + describe("mirror subcommand", () => { + it("should parse mirror with source and target", async () => { + const result = await parseBlossomCommand([ + "mirror", + "https://source.com/blob", + "target.com", + ]); + expect(result.subcommand).toBe("mirror"); + expect(result.sourceUrl).toBe("https://source.com/blob"); + expect(result.targetServer).toBe("https://target.com"); + }); + + it("should throw error when source URL missing", async () => { + await expect(parseBlossomCommand(["mirror"])).rejects.toThrow( + "Source URL and target server required", + ); + }); + + it("should throw error when target server missing", async () => { + await expect( + parseBlossomCommand(["mirror", "https://source.com/blob"]), + ).rejects.toThrow("Source URL and target server required"); + }); + }); + + describe("delete subcommand", () => { + const validSha256 = + "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"; + + it("should parse delete with sha256 and server", async () => { + const result = await parseBlossomCommand([ + "delete", + validSha256, + "blossom.primal.net", + ]); + expect(result.subcommand).toBe("delete"); + expect(result.sha256).toBe(validSha256); + expect(result.serverUrl).toBe("https://blossom.primal.net"); + }); + + it("should parse delete alias 'rm'", async () => { + const result = await parseBlossomCommand([ + "rm", + validSha256, + "server.com", + ]); + expect(result.subcommand).toBe("delete"); + }); + + it("should throw error when sha256 missing", async () => { + await expect(parseBlossomCommand(["delete"])).rejects.toThrow( + "SHA256 hash and server required", + ); + }); + + it("should throw error when server missing", async () => { + await expect( + parseBlossomCommand(["delete", validSha256]), + ).rejects.toThrow("SHA256 hash and server required"); + }); + + it("should throw error for invalid sha256", async () => { + await expect( + parseBlossomCommand(["delete", "invalid", "server.com"]), + ).rejects.toThrow("Invalid SHA256 hash"); + }); + }); + + describe("unknown subcommand", () => { + it("should throw error with help text for unknown subcommand", async () => { + await expect(parseBlossomCommand(["unknown"])).rejects.toThrow( + /Unknown subcommand: unknown/, + ); + }); + + it("should include available subcommands in error", async () => { + try { + await parseBlossomCommand(["invalid"]); + } catch (e) { + const error = e as Error; + expect(error.message).toContain("servers"); + expect(error.message).toContain("server "); + expect(error.message).toContain("upload"); + expect(error.message).toContain("list"); + expect(error.message).toContain("blob"); + expect(error.message).toContain("mirror"); + expect(error.message).toContain("delete"); + } + }); + }); + + describe("case insensitivity", () => { + it("should handle uppercase subcommands", async () => { + const result = await parseBlossomCommand(["SERVERS"]); + expect(result.subcommand).toBe("servers"); + }); + + it("should handle mixed case subcommands", async () => { + const result = await parseBlossomCommand(["Upload"]); + expect(result.subcommand).toBe("upload"); + }); + }); +}); diff --git a/src/lib/blossom-parser.ts b/src/lib/blossom-parser.ts new file mode 100644 index 0000000..515fbd4 --- /dev/null +++ b/src/lib/blossom-parser.ts @@ -0,0 +1,228 @@ +/** + * Blossom Command Parser + * + * Parses arguments for the blossom command with subcommands: + * - servers: Show/manage user's Blossom server list + * - server : View info about a specific Blossom server + * - upload: Upload a file (handled by UI file picker) + * - list [pubkey]: List blobs for a user + * - blob [server]: View a specific blob + * - mirror : Mirror a blob to another server + * - delete : Delete a blob from a server + */ + +import { nip19 } from "nostr-tools"; +import { isNip05, resolveNip05 } from "./nip05"; +import { isValidHexPubkey, normalizeHex } from "./nostr-validation"; + +export type BlossomSubcommand = + | "servers" + | "server" + | "upload" + | "list" + | "blob" + | "mirror" + | "delete"; + +export interface BlossomCommandResult { + subcommand: BlossomSubcommand; + // For 'blob' and 'delete' subcommands + sha256?: string; + serverUrl?: string; + // For 'list' subcommand + pubkey?: string; + // For 'mirror' subcommand + sourceUrl?: string; + targetServer?: string; +} + +/** + * Normalize a server URL (add https:// if missing) + */ +function normalizeServerUrl(url: string): string { + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + return `https://${url}`; +} + +/** + * Resolve a pubkey from various formats (npub, nprofile, hex, NIP-05, $me) + */ +async function resolvePubkey( + input: string, + activeAccountPubkey?: string, +): Promise { + // Handle $me alias + if (input === "$me") { + return activeAccountPubkey; + } + + // Handle hex pubkey + if (isValidHexPubkey(input)) { + return normalizeHex(input); + } + + // Handle npub + if (input.startsWith("npub1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "npub") { + return decoded.data; + } + } catch { + // Invalid npub + } + } + + // Handle nprofile + if (input.startsWith("nprofile1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "nprofile") { + return decoded.data.pubkey; + } + } catch { + // Invalid nprofile + } + } + + // Handle NIP-05 identifier (user@domain.com or domain.com) + if (isNip05(input)) { + const pubkey = await resolveNip05(input); + if (pubkey) { + return pubkey; + } + } + + return undefined; +} + +/** + * Parse blossom command arguments + * + * Usage: + * blossom servers - Show your Blossom servers + * blossom server - View info about a specific server + * blossom upload - Open upload dialog + * blossom list [pubkey] - List blobs (defaults to $me) + * blossom blob [server] - View blob details + * blossom mirror - Mirror blob to server + * blossom delete - Delete blob from server + */ +export async function parseBlossomCommand( + args: string[], + activeAccountPubkey?: string, +): Promise { + // Default to 'servers' if no subcommand + if (args.length === 0) { + return { subcommand: "servers" }; + } + + const subcommand = args[0].toLowerCase(); + + switch (subcommand) { + case "servers": + return { subcommand: "servers" }; + + case "server": { + // View info about a specific Blossom server + if (args.length < 2) { + throw new Error("Server URL required. Usage: blossom server "); + } + return { + subcommand: "server", + serverUrl: normalizeServerUrl(args[1]), + }; + } + + case "upload": + return { subcommand: "upload" }; + + case "list": + case "ls": { + // Default to active account if no pubkey specified + const pubkeyArg = args[1]; + let pubkey: string | undefined; + + if (pubkeyArg) { + pubkey = await resolvePubkey(pubkeyArg, activeAccountPubkey); + if (!pubkey) { + throw new Error( + `Invalid pubkey format: ${pubkeyArg}. Use npub, nprofile, hex, user@domain.com, or $me`, + ); + } + } else { + pubkey = activeAccountPubkey; + } + + return { + subcommand: "list", + pubkey, + }; + } + + case "blob": + case "view": { + if (args.length < 2) { + throw new Error( + "SHA256 hash required. Usage: blossom blob [server]", + ); + } + const sha256 = args[1].toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(sha256)) { + throw new Error("Invalid SHA256 hash. Must be 64 hex characters."); + } + return { + subcommand: "blob", + sha256, + serverUrl: args[2] ? normalizeServerUrl(args[2]) : undefined, + }; + } + + case "mirror": { + if (args.length < 3) { + throw new Error( + "Source URL and target server required. Usage: blossom mirror ", + ); + } + return { + subcommand: "mirror", + sourceUrl: args[1], + targetServer: normalizeServerUrl(args[2]), + }; + } + + case "delete": + case "rm": { + if (args.length < 3) { + throw new Error( + "SHA256 hash and server required. Usage: blossom delete ", + ); + } + const sha256 = args[1].toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(sha256)) { + throw new Error("Invalid SHA256 hash. Must be 64 hex characters."); + } + return { + subcommand: "delete", + sha256, + serverUrl: normalizeServerUrl(args[2]), + }; + } + + default: + throw new Error( + `Unknown subcommand: ${subcommand} + +Available subcommands: + servers Show your configured Blossom servers + server View info about a specific server + upload Open file upload dialog + list [pubkey] List blobs (defaults to your account) + blob [server] View blob details + mirror Mirror a blob to another server + delete Delete a blob from a server`, + ); + } +} diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index 48dbdf7..73c6354 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -17,6 +17,22 @@ import type { GetActionsOptions, } from "@/types/chat-actions"; +/** + * Blob attachment metadata for imeta tags (NIP-92) + */ +export interface BlobAttachmentMeta { + /** The URL of the blob */ + url: string; + /** SHA256 hash of the blob content */ + sha256: string; + /** MIME type of the blob */ + mimeType?: string; + /** Size in bytes */ + size?: number; + /** Blossom server URL */ + server?: string; +} + /** * Options for sending a message */ @@ -25,6 +41,8 @@ export interface SendMessageOptions { replyTo?: string; /** NIP-30 custom emoji tags */ emojiTags?: Array<{ shortcode: string; url: string }>; + /** Blob attachments for imeta tags (NIP-92) */ + blobAttachments?: BlobAttachmentMeta[]; } /** diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 87b698e..a4dcc30 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -469,6 +469,17 @@ export class Nip29Adapter extends ChatProtocolAdapter { } } + // Add NIP-92 imeta tags for blob attachments + if (options?.blobAttachments) { + for (const blob of options.blobAttachments) { + const imetaParts = [`url ${blob.url}`]; + if (blob.sha256) imetaParts.push(`x ${blob.sha256}`); + if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`); + if (blob.size) imetaParts.push(`size ${blob.size}`); + tags.push(["imeta", ...imetaParts]); + } + } + // Use kind 9 for group chat messages const draft = await factory.build({ kind: 9, content, tags }); const event = await factory.sign(draft); diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 871fa86..54dba8f 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -450,6 +450,17 @@ export class Nip53Adapter extends ChatProtocolAdapter { } } + // Add NIP-92 imeta tags for blob attachments + if (options?.blobAttachments) { + for (const blob of options.blobAttachments) { + const imetaParts = [`url ${blob.url}`]; + if (blob.sha256) imetaParts.push(`x ${blob.sha256}`); + if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`); + if (blob.size) imetaParts.push(`size ${blob.size}`); + tags.push(["imeta", ...imetaParts]); + } + } + // Use kind 1311 for live chat messages const draft = await factory.build({ kind: 1311, content, tags }); const event = await factory.sign(draft); diff --git a/src/services/blossom.ts b/src/services/blossom.ts new file mode 100644 index 0000000..dd3e37a --- /dev/null +++ b/src/services/blossom.ts @@ -0,0 +1,359 @@ +/** + * Blossom Service + * + * Wraps blossom-client-sdk for blob storage operations. + * Integrates with Grimoire's account system for signing. + * + * Key features: + * - Upload blobs to user's configured Blossom servers + * - List blobs for a pubkey + * - Check server health + * - Mirror blobs between servers + * - Manage user server lists (kind 10063) + */ + +import { + BlossomClient, + type BlobDescriptor, + type SignedEvent, + getServersFromServerListEvent, +} from "blossom-client-sdk"; +import type { EventTemplate } from "nostr-tools/core"; +import accountManager from "./accounts"; +import eventStore from "./event-store"; +import { addressLoader } from "./loaders"; +import type { Subscription } from "rxjs"; + +/** Kind for user's Blossom server list (BUD-03) */ +export const USER_SERVER_LIST_KIND = 10063; + +/** Re-export types from SDK */ +export type { BlobDescriptor, SignedEvent }; + +/** + * Server info parsed from kind 10063 event + */ +export interface BlossomServerInfo { + url: string; + // Future: could add server-specific metadata +} + +/** + * Result of an upload operation + */ +export interface UploadResult { + blob: BlobDescriptor; + server: string; +} + +/** + * Result of checking a server + */ +export interface ServerCheckResult { + url: string; + online: boolean; + error?: string; + responseTime?: number; +} + +/** + * Get signer function for the active account + * Compatible with blossom-client-sdk's signer interface + */ +function getActiveSigner(): + | ((event: EventTemplate) => Promise) + | null { + const account = accountManager.active; + if (!account?.signer) return null; + + return async (event: EventTemplate): Promise => { + const signer = account.signer; + if (!signer) throw new Error("No signer available"); + + // applesauce signers have a signEvent method + const signed = await signer.signEvent(event); + return signed as SignedEvent; + }; +} + +/** + * Get user's Blossom servers from their kind 10063 event + */ +export function getServersFromEvent(event: { tags: string[][] }): string[] { + // SDK returns URL objects, convert to strings + const urls = getServersFromServerListEvent(event); + return urls.map((url) => url.toString()); +} + +/** + * Fetch user's Blossom server list from the network + * Returns servers from kind 10063 event + */ +export async function fetchUserServers(pubkey: string): Promise { + return new Promise((resolve) => { + let subscription: Subscription | null = null; + let resolved = false; + + // Set a timeout to resolve with empty array if no response + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + subscription?.unsubscribe(); + + // Check if we have the event in store + const event = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + pubkey, + "", + ); + if (event) { + resolve(getServersFromEvent(event)); + } else { + resolve([]); + } + } + }, 5000); + + subscription = addressLoader({ + kind: USER_SERVER_LIST_KIND, + pubkey, + identifier: "", + }).subscribe({ + next: () => { + // Event arrived, check store + const event = eventStore.getReplaceable( + USER_SERVER_LIST_KIND, + pubkey, + "", + ); + if (event && !resolved) { + resolved = true; + clearTimeout(timeout); + subscription?.unsubscribe(); + resolve(getServersFromEvent(event)); + } + }, + error: () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve([]); + } + }, + }); + }); +} + +/** + * Check if a Blossom server is online and responsive + */ +export async function checkServer( + serverUrl: string, +): Promise { + const url = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`; + const start = Date.now(); + + try { + // Try to fetch a non-existent blob - server should respond with 404 + // This tests basic connectivity without requiring auth + const response = await fetch( + `${url}0000000000000000000000000000000000000000000000000000000000000000`, + { + method: "HEAD", + signal: AbortSignal.timeout(10000), + }, + ); + + const responseTime = Date.now() - start; + + // 404 is expected for non-existent blob, 200 would mean blob exists + // Both indicate server is online + if (response.status === 404 || response.status === 200) { + return { url: serverUrl, online: true, responseTime }; + } + + return { + url: serverUrl, + online: false, + error: `Unexpected status: ${response.status}`, + responseTime, + }; + } catch (error) { + return { + url: serverUrl, + online: false, + error: error instanceof Error ? error.message : "Unknown error", + responseTime: Date.now() - start, + }; + } +} + +/** + * Upload a file to a Blossom server + * Requires an active account with signer + */ +export async function uploadBlob( + file: File, + serverUrl: string, +): Promise { + const signer = getActiveSigner(); + if (!signer) { + throw new Error("No active account or signer available"); + } + + const client = new BlossomClient(serverUrl, signer); + const blob = await client.uploadBlob(file); + + return { + blob, + server: serverUrl, + }; +} + +/** + * Upload a file to multiple servers + * Returns results for each server (success or error) + */ +export async function uploadBlobToServers( + file: File, + servers: string[], +): Promise<{ + results: UploadResult[]; + errors: { server: string; error: string }[]; +}> { + const signer = getActiveSigner(); + if (!signer) { + throw new Error("No active account or signer available"); + } + + const results: UploadResult[] = []; + const errors: { server: string; error: string }[] = []; + + // Upload to servers in parallel + const uploads = servers.map(async (server) => { + try { + const client = new BlossomClient(server, signer); + const blob = await client.uploadBlob(file); + results.push({ blob, server }); + } catch (error) { + errors.push({ + server, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + await Promise.all(uploads); + + return { results, errors }; +} + +/** + * List blobs uploaded by a pubkey from a server + */ +export async function listBlobs( + serverUrl: string, + pubkey: string, + options?: { limit?: number; since?: number; until?: number }, +): Promise { + const signer = getActiveSigner(); + + // BlossomClient can work without signer for listing public blobs + const client = new BlossomClient(serverUrl, signer || undefined); + + const blobs = await client.listBlobs(pubkey, options); + return blobs; +} + +/** + * Delete a blob from a server + * Requires the blob to have been uploaded by the active account + */ +export async function deleteBlob( + serverUrl: string, + sha256: string, +): Promise { + const signer = getActiveSigner(); + if (!signer) { + throw new Error("No active account or signer available"); + } + + const client = new BlossomClient(serverUrl, signer); + await client.deleteBlob(sha256); +} + +/** + * Mirror a blob from one URL to a server + * The sourceUrl should be a Blossom blob URL (server/) + */ +export async function mirrorBlob( + sourceUrl: string, + targetServer: string, +): Promise { + const signer = getActiveSigner(); + if (!signer) { + throw new Error("No active account or signer available"); + } + + // Create a BlobDescriptor from the source URL + // Extract sha256 from URL (format: https://server/ or https://server/.ext) + const urlObj = new URL(sourceUrl); + const pathParts = urlObj.pathname.split("/").filter(Boolean); + const lastPart = pathParts[pathParts.length - 1]; + // Remove extension if present + const sha256 = lastPart.replace(/\.[^.]+$/, ""); + + if (!/^[0-9a-f]{64}$/i.test(sha256)) { + throw new Error("Invalid blob URL - could not extract SHA256 hash"); + } + + const blobDescriptor: BlobDescriptor = { + sha256: sha256.toLowerCase(), + size: 0, // Unknown + url: sourceUrl, + uploaded: 0, // Unknown + }; + + const client = new BlossomClient(targetServer, signer); + const result = await client.mirrorBlob(blobDescriptor); + return result; +} + +/** + * Get a blob's URL on a specific server + */ +export function getBlobUrl( + serverUrl: string, + sha256: string, + extension?: string, +): string { + const base = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`; + return extension ? `${base}${sha256}.${extension}` : `${base}${sha256}`; +} + +/** + * Get the active account's configured Blossom servers + * Fetches from kind 10063 if available + */ +export async function getActiveAccountServers(): Promise { + const account = accountManager.active; + if (!account?.pubkey) return []; + + return fetchUserServers(account.pubkey); +} + +// Default export for convenience +export default { + USER_SERVER_LIST_KIND, + getActiveSigner, + getServersFromEvent, + fetchUserServers, + checkServer, + uploadBlob, + uploadBlobToServers, + listBlobs, + deleteBlob, + mirrorBlob, + getBlobUrl, + getActiveAccountServers, +}; diff --git a/src/types/app.ts b/src/types/app.ts index 4b88e97..6c42a94 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -19,6 +19,7 @@ export type AppId = | "chat" | "spells" | "spellbooks" + | "blossom" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index a97b2bd..d12be49 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -6,6 +6,7 @@ import { parseProfileCommand } from "@/lib/profile-parser"; import { parseRelayCommand } from "@/lib/relay-parser"; import { resolveNip05Batch } from "@/lib/nip05"; import { parseChatCommand } from "@/lib/chat-parser"; +import { parseBlossomCommand } from "@/lib/blossom-parser"; export interface ManPageEntry { name: string; @@ -515,4 +516,63 @@ export const manPages: Record = { category: "Nostr", defaultProps: {}, }, + blossom: { + name: "blossom", + section: "1", + synopsis: "blossom [options]", + description: + "Manage blob storage on Blossom servers. Upload, list, and manage media files using the Blossom protocol (BUD specs). Your Blossom server list is stored in a kind 10063 event.", + options: [ + { + flag: "servers", + description: + "Show your configured Blossom servers from kind 10063 event", + }, + { + flag: "server ", + description: "View info about a specific Blossom server", + }, + { + flag: "upload", + description: + "Open file upload dialog to upload files to your Blossom servers", + }, + { + flag: "list [pubkey]", + description: + "List blobs uploaded by a user. Supports npub, hex, NIP-05 (user@domain.com), or $me", + }, + { + flag: "blob [server]", + description: + "View details and preview of a specific blob by its SHA256 hash", + }, + { + flag: "mirror ", + description: "Mirror a blob from a URL to another Blossom server", + }, + { + flag: "delete ", + description: "Delete a blob from a Blossom server", + }, + ], + examples: [ + "blossom Show your Blossom servers", + "blossom servers Show your Blossom servers", + "blossom server blossom.primal.net View specific server info", + "blossom upload Open file upload dialog", + "blossom list List your uploaded blobs", + "blossom list fiatjaf.com List blobs for a NIP-05 user", + "blossom list npub1... List blobs for another user", + "blossom blob abc123... View blob details", + "blossom mirror https://... cdn.example.com Mirror blob to server", + ], + seeAlso: ["profile"], + appId: "blossom", + category: "Nostr", + argParser: async (args: string[], activeAccountPubkey?: string) => { + return await parseBlossomCommand(args, activeAccountPubkey); + }, + defaultProps: { subcommand: "servers" }, + }, };