Merge main branch, resolve conflicts keeping NIP-51 lists and Blossom support

This commit is contained in:
Claude
2026-01-13 16:22:18 +00:00
20 changed files with 3418 additions and 20 deletions

57
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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],
);

View 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>
);
}

View File

@@ -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)

View File

@@ -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",

View 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 };
}

View 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
View 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`,
);
}
}

View File

@@ -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[];
}
/**

View File

@@ -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);

View File

@@ -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
View 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,
};

View File

@@ -19,6 +19,7 @@ export type AppId =
| "chat"
| "spells"
| "spellbooks"
| "blossom"
| "win";
export interface WindowInstance {

View File

@@ -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" },
},
};