mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
Merge main branch, resolve conflicts keeping NIP-51 lists and Blossom support
This commit is contained in:
57
package-lock.json
generated
57
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
553
src/components/BlossomUploadDialog.tsx
Normal file
553
src/components/BlossomUploadDialog.tsx
Normal file
@@ -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<string[]>([]);
|
||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [loadingServers, setLoadingServers] = useState(true);
|
||||
const [usingFallback, setUsingFallback] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploadResults, setUploadResults] = useState<UploadResult[]>([]);
|
||||
const [uploadErrors, setUploadErrors] = useState<
|
||||
{ server: string; error: string }[]
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropZoneRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload to Blossom</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sign in to upload files to your Blossom servers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Upload className="size-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Account required to upload files
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="size-5" />
|
||||
Upload to Blossom
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a file and choose which servers to upload to.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* File Selection / Drop Zone */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="border-2 border-dashed rounded-lg p-4 text-center transition-colors hover:border-primary/50 cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-32 max-w-full rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(
|
||||
selectedFile.type,
|
||||
"size-12 text-muted-foreground",
|
||||
)
|
||||
)}
|
||||
<p className="font-medium text-sm truncate max-w-full">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSize(selectedFile.size)} •{" "}
|
||||
{selectedFile.type || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<Upload className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click or drop a file here
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Images, videos, or audio
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server Selection */}
|
||||
{loadingServers ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{usingFallback ? (
|
||||
<Globe className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<HardDrive className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{usingFallback ? "Public Servers" : "Your Servers"} (
|
||||
{selectedServers.size}/{servers.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={selectAll}
|
||||
disabled={uploading}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={selectNone}
|
||||
disabled={uploading}
|
||||
>
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{usingFallback && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
No server list found. Using public servers.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{servers.map((server) => (
|
||||
<label
|
||||
key={server}
|
||||
className="flex items-center gap-2 p-1.5 rounded hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedServers.has(server)}
|
||||
onCheckedChange={() => toggleServer(server)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{usingFallback ? (
|
||||
<Globe className="size-3.5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<HardDrive className="size-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
{server}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="border rounded-lg p-3 bg-green-50 dark:bg-green-950/30">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-2">
|
||||
<CheckCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Uploaded ({uploadResults.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{uploadResults.map((result) => (
|
||||
<code
|
||||
key={result.server}
|
||||
className="text-xs block truncate text-green-700 dark:text-green-400"
|
||||
>
|
||||
{result.blob.url}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Errors */}
|
||||
{uploadErrors.length > 0 && (
|
||||
<div className="border rounded-lg p-3 bg-red-50 dark:bg-red-950/30">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-2">
|
||||
<XCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Failed ({uploadErrors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{uploadErrors.map((error) => (
|
||||
<div
|
||||
key={error.server}
|
||||
className="text-xs text-red-700 dark:text-red-400"
|
||||
>
|
||||
{new URL(error.server).hostname}: {error.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleClose}
|
||||
disabled={uploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleUpload}
|
||||
disabled={
|
||||
uploading || !selectedFile || selectedServers.size === 0
|
||||
}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-4 mr-2" />
|
||||
Upload
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for file type
|
||||
*/
|
||||
function getFileIcon(mimeType?: string, className = "size-4") {
|
||||
if (!mimeType) return <FileIcon className={className} />;
|
||||
if (mimeType.startsWith("image/")) return <ImageIcon className={className} />;
|
||||
if (mimeType.startsWith("video/")) return <Film className={className} />;
|
||||
if (mimeType.startsWith("audio/")) return <Music className={className} />;
|
||||
if (mimeType.startsWith("text/")) return <FileText className={className} />;
|
||||
if (mimeType.includes("zip") || mimeType.includes("archive"))
|
||||
return <Archive className={className} />;
|
||||
return <FileIcon className={className} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
1271
src/components/BlossomViewer.tsx
Normal file
1271
src/components/BlossomViewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<MentionEditorHandle>(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<VirtuosoHandle>(null);
|
||||
|
||||
// Ref to MentionEditor for programmatic submission
|
||||
const editorRef = useRef<MentionEditorHandle>(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({
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={openUpload}
|
||||
disabled={isSending}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Attach media</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Type a message..."
|
||||
@@ -858,9 +907,9 @@ export function ChatViewer({
|
||||
searchEmojis={searchEmojis}
|
||||
searchCommands={searchCommands}
|
||||
onCommandExecute={handleCommandExecute}
|
||||
onSubmit={(content, emojiTags) => {
|
||||
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 ? <Loader2 className="size-3 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
{uploadDialog}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
|
||||
// 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) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Blossom servers dropdown */}
|
||||
{blossomServers.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={`${blossomServers.length} Blossom server${blossomServers.length !== 1 ? "s" : ""}`}
|
||||
>
|
||||
<HardDrive className="size-3" />
|
||||
<span>{blossomServers.length}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
{blossomServers.map((url) => (
|
||||
<DropdownMenuItem
|
||||
key={url}
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => window.open(url, "_blank")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<HardDrive className="size-3 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-mono text-xs truncate">{url}</span>
|
||||
</div>
|
||||
<ExternalLink className="size-3 text-muted-foreground flex-shrink-0" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = <SpellbooksViewer />;
|
||||
break;
|
||||
case "blossom":
|
||||
content = (
|
||||
<BlossomViewer
|
||||
subcommand={window.props.subcommand}
|
||||
serverUrl={window.props.serverUrl}
|
||||
pubkey={window.props.pubkey}
|
||||
sourceUrl={window.props.sourceUrl}
|
||||
targetServer={window.props.targetServer}
|
||||
sha256={window.props.sha256}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -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<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
searchCommands?: (query: string) => Promise<ChatAction[]>;
|
||||
@@ -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<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
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],
|
||||
);
|
||||
|
||||
127
src/components/nostr/kinds/BlossomServerListRenderer.tsx
Normal file
127
src/components/nostr/kinds/BlossomServerListRenderer.tsx
Normal file
@@ -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 (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
No Blossom servers configured
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{servers.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 py-0.5 group cursor-pointer hover:bg-muted/30 rounded px-1 -mx-1"
|
||||
onClick={() => handleServerClick(url)}
|
||||
>
|
||||
<HardDrive className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-mono text-xs underline decoration-dotted flex-1 truncate">
|
||||
{url}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No Blossom servers configured
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<HardDrive className="size-4" />
|
||||
<span>Blossom Servers ({servers.length})</span>
|
||||
</div>
|
||||
{servers.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-3 p-2 rounded hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleServerClick(url)}
|
||||
>
|
||||
<HardDrive className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-mono text-sm underline decoration-dotted flex-1 truncate">
|
||||
{url}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number, React.ComponentType<BaseEventProps>> = {
|
||||
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)
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
BookHeart,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Heart,
|
||||
Highlighter,
|
||||
@@ -828,13 +829,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
// 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",
|
||||
|
||||
93
src/hooks/useBlossomUpload.tsx
Normal file
93
src/hooks/useBlossomUpload.tsx
Normal file
@@ -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 (
|
||||
* <>
|
||||
* <button onClick={open}>Upload</button>
|
||||
* {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(
|
||||
() => (
|
||||
<BlossomUploadDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
onError={handleError}
|
||||
accept={options.accept}
|
||||
/>
|
||||
),
|
||||
[isOpen, handleSuccess, handleCancel, handleError, options.accept],
|
||||
);
|
||||
|
||||
return { open, close, isOpen, dialog };
|
||||
}
|
||||
285
src/lib/blossom-parser.test.ts
Normal file
285
src/lib/blossom-parser.test.ts
Normal file
@@ -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 <url>");
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
228
src/lib/blossom-parser.ts
Normal file
228
src/lib/blossom-parser.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Blossom Command Parser
|
||||
*
|
||||
* Parses arguments for the blossom command with subcommands:
|
||||
* - servers: Show/manage user's Blossom server list
|
||||
* - server <url>: View info about a specific Blossom server
|
||||
* - upload: Upload a file (handled by UI file picker)
|
||||
* - list [pubkey]: List blobs for a user
|
||||
* - blob <sha256> [server]: View a specific blob
|
||||
* - mirror <url> <server>: Mirror a blob to another server
|
||||
* - delete <sha256> <server>: 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<string | undefined> {
|
||||
// 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 <url> - View info about a specific server
|
||||
* blossom upload - Open upload dialog
|
||||
* blossom list [pubkey] - List blobs (defaults to $me)
|
||||
* blossom blob <sha256> [server] - View blob details
|
||||
* blossom mirror <url> <server> - Mirror blob to server
|
||||
* blossom delete <sha256> <server> - Delete blob from server
|
||||
*/
|
||||
export async function parseBlossomCommand(
|
||||
args: string[],
|
||||
activeAccountPubkey?: string,
|
||||
): Promise<BlossomCommandResult> {
|
||||
// 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 <url>");
|
||||
}
|
||||
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 <sha256> [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 <url> <server>",
|
||||
);
|
||||
}
|
||||
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 <sha256> <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: "delete",
|
||||
sha256,
|
||||
serverUrl: normalizeServerUrl(args[2]),
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown subcommand: ${subcommand}
|
||||
|
||||
Available subcommands:
|
||||
servers Show your configured Blossom servers
|
||||
server <url> View info about a specific server
|
||||
upload Open file upload dialog
|
||||
list [pubkey] List blobs (defaults to your account)
|
||||
blob <sha256> [server] View blob details
|
||||
mirror <url> <server> Mirror a blob to another server
|
||||
delete <sha256> <server> Delete a blob from a server`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
359
src/services/blossom.ts
Normal file
359
src/services/blossom.ts
Normal file
@@ -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<SignedEvent>)
|
||||
| null {
|
||||
const account = accountManager.active;
|
||||
if (!account?.signer) return null;
|
||||
|
||||
return async (event: EventTemplate): Promise<SignedEvent> => {
|
||||
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<string[]> {
|
||||
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<ServerCheckResult> {
|
||||
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<UploadResult> {
|
||||
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<BlobDescriptor[]> {
|
||||
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<void> {
|
||||
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/<sha256>)
|
||||
*/
|
||||
export async function mirrorBlob(
|
||||
sourceUrl: string,
|
||||
targetServer: string,
|
||||
): Promise<BlobDescriptor> {
|
||||
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/<sha256> or https://server/<sha256>.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<string[]> {
|
||||
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,
|
||||
};
|
||||
@@ -19,6 +19,7 @@ export type AppId =
|
||||
| "chat"
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -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<string, ManPageEntry> = {
|
||||
category: "Nostr",
|
||||
defaultProps: {},
|
||||
},
|
||||
blossom: {
|
||||
name: "blossom",
|
||||
section: "1",
|
||||
synopsis: "blossom <subcommand> [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 <url>",
|
||||
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 <sha256> [server]",
|
||||
description:
|
||||
"View details and preview of a specific blob by its SHA256 hash",
|
||||
},
|
||||
{
|
||||
flag: "mirror <url> <server>",
|
||||
description: "Mirror a blob from a URL to another Blossom server",
|
||||
},
|
||||
{
|
||||
flag: "delete <sha256> <server>",
|
||||
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" },
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user