Add individual server view and NIP-05 support for blossom commands

- Add 'server' subcommand to view info about a specific Blossom server
- Update BlossomServerListRenderer to open server view on click
- Make blossom parser async to support NIP-05 resolution in 'list' command
- Add kind 10063 (Blossom Server List) to EVENT_KINDS constants with BUD-03 reference
- Update command examples with NIP-05 identifier support
This commit is contained in:
Claude
2026-01-13 16:04:11 +00:00
parent 94788a7926
commit 27fbe9a8fc
5 changed files with 186 additions and 26 deletions

View File

@@ -67,6 +67,8 @@ export function BlossomViewer({
switch (subcommand) {
case "servers":
return <ServersView />;
case "server":
return <ServerView serverUrl={serverUrl!} />;
case "upload":
return <UploadView />;
case "list":
@@ -306,6 +308,134 @@ function ServerRow({
);
}
/**
* ServerView - View info about a specific Blossom server
*/
function ServerView({ serverUrl }: { serverUrl: string }) {
const { copy, copied } = useCopy();
const [status, setStatus] = useState<ServerCheckResult | null>(null);
const [loading, setLoading] = useState(true);
// Check server status on mount
useEffect(() => {
let cancelled = false;
const check = async () => {
setLoading(true);
const result = await checkServer(serverUrl);
if (!cancelled) {
setStatus(result);
setLoading(false);
}
};
check();
return () => {
cancelled = true;
};
}, [serverUrl]);
const hostname = (() => {
try {
return new URL(serverUrl).hostname;
} catch {
return serverUrl;
}
})();
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b px-4 py-2 flex items-center gap-2">
<HardDrive className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Blossom Server</span>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Server Info */}
<div className="border rounded-lg divide-y">
<div className="px-4 py-3">
<div className="text-xs text-muted-foreground uppercase mb-1">
URL
</div>
<div className="flex items-center gap-2">
<code className="text-sm break-all flex-1">{serverUrl}</code>
<Button
variant="ghost"
size="icon"
onClick={() => copy(serverUrl)}
>
{copied ? (
<CopyCheck className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => window.open(serverUrl, "_blank")}
>
<ExternalLink className="size-4" />
</Button>
</div>
</div>
<div className="px-4 py-3">
<div className="text-xs text-muted-foreground uppercase mb-1">
Hostname
</div>
<div className="text-sm">{hostname}</div>
</div>
<div className="px-4 py-3">
<div className="text-xs text-muted-foreground uppercase mb-1">
Status
</div>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Checking...
</span>
</div>
) : status ? (
<div className="flex items-center gap-2">
{status.online ? (
<>
<CheckCircle className="size-4 text-green-500" />
<span className="text-sm text-green-600">
Online ({status.responseTime}ms)
</span>
</>
) : (
<>
<XCircle className="size-4 text-red-500" />
<span className="text-sm text-red-600">{status.error}</span>
</>
)}
</div>
) : null}
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => window.open(serverUrl, "_blank")}
>
<ExternalLink className="size-4 mr-2" />
Open in Browser
</Button>
</div>
</div>
</div>
);
}
/**
* UploadView - File upload interface with server selection
*/

View File

@@ -14,11 +14,11 @@ export function BlossomServerListRenderer({ event }: BaseEventProps) {
const servers = getServersFromEvent(event);
const handleServerClick = (serverUrl: string) => {
// Open the blossom viewer with server info
// Open the blossom viewer with specific server info
addWindow(
"blossom",
{ subcommand: "servers", serverUrl },
`blossom servers`,
{ subcommand: "server", serverUrl },
`blossom server ${serverUrl}`,
undefined,
);
};
@@ -79,8 +79,8 @@ export function BlossomServerListDetailRenderer({
const handleServerClick = (serverUrl: string) => {
addWindow(
"blossom",
{ subcommand: "servers", serverUrl },
`blossom servers`,
{ subcommand: "server", serverUrl },
`blossom server ${serverUrl}`,
undefined,
);
};

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

@@ -3,6 +3,7 @@
*
* 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
@@ -11,9 +12,12 @@
*/
import { nip19 } from "nostr-tools";
import { isNip05, resolveNip05 } from "./nip05";
import { isValidHexPubkey, normalizeHex } from "./nostr-validation";
export type BlossomSubcommand =
| "servers"
| "server"
| "upload"
| "list"
| "blob"
@@ -43,20 +47,20 @@ function normalizeServerUrl(url: string): string {
}
/**
* Resolve a pubkey from various formats (npub, nprofile, hex, $me)
* Resolve a pubkey from various formats (npub, nprofile, hex, NIP-05, $me)
*/
function resolvePubkey(
async function resolvePubkey(
input: string,
activeAccountPubkey?: string,
): string | undefined {
): Promise<string | undefined> {
// Handle $me alias
if (input === "$me") {
return activeAccountPubkey;
}
// Handle hex pubkey
if (/^[0-9a-f]{64}$/i.test(input)) {
return input.toLowerCase();
if (isValidHexPubkey(input)) {
return normalizeHex(input);
}
// Handle npub
@@ -83,6 +87,14 @@ function resolvePubkey(
}
}
// Handle NIP-05 identifier (user@domain.com or domain.com)
if (isNip05(input)) {
const pubkey = await resolveNip05(input);
if (pubkey) {
return pubkey;
}
}
return undefined;
}
@@ -91,16 +103,17 @@ function resolvePubkey(
*
* 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 function parseBlossomCommand(
export async function parseBlossomCommand(
args: string[],
activeAccountPubkey?: string,
): BlossomCommandResult {
): Promise<BlossomCommandResult> {
// Default to 'servers' if no subcommand
if (args.length === 0) {
return { subcommand: "servers" };
@@ -110,9 +123,19 @@ export function parseBlossomCommand(
switch (subcommand) {
case "servers":
case "server":
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" };
@@ -123,10 +146,10 @@ export function parseBlossomCommand(
let pubkey: string | undefined;
if (pubkeyArg) {
pubkey = resolvePubkey(pubkeyArg, activeAccountPubkey);
pubkey = await resolvePubkey(pubkeyArg, activeAccountPubkey);
if (!pubkey) {
throw new Error(
`Invalid pubkey format: ${pubkeyArg}. Use npub, nprofile, hex, or $me`,
`Invalid pubkey format: ${pubkeyArg}. Use npub, nprofile, hex, user@domain.com, or $me`,
);
}
} else {
@@ -194,6 +217,7 @@ export function parseBlossomCommand(
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

View File

@@ -528,6 +528,10 @@ export const manPages: Record<string, ManPageEntry> = {
description:
"Show your configured Blossom servers from kind 10063 event",
},
{
flag: "server <url>",
description: "View info about a specific Blossom server",
},
{
flag: "upload",
description:
@@ -536,7 +540,7 @@ export const manPages: Record<string, ManPageEntry> = {
{
flag: "list [pubkey]",
description:
"List blobs uploaded by a user (defaults to your account). Supports npub, hex, or $me",
"List blobs uploaded by a user. Supports npub, hex, NIP-05 (user@domain.com), or $me",
},
{
flag: "blob <sha256> [server]",
@@ -555,9 +559,10 @@ export const manPages: Record<string, ManPageEntry> = {
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 $me 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",
@@ -565,8 +570,8 @@ export const manPages: Record<string, ManPageEntry> = {
seeAlso: ["profile"],
appId: "blossom",
category: "Nostr",
argParser: (args: string[], activeAccountPubkey?: string) => {
return parseBlossomCommand(args, activeAccountPubkey);
argParser: async (args: string[], activeAccountPubkey?: string) => {
return await parseBlossomCommand(args, activeAccountPubkey);
},
defaultProps: { subcommand: "servers" },
},