mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
feat: implement Communikeys standard support
Add support for Communikeys, a decentralized community management system on Nostr that uses existing keypairs and relays: - Add kind 10222 (Community Creation Event) support with renderers - Add kind 30222 (Targeted Publication Event) support with renderers - Create communikeys-helpers.ts for metadata extraction - Create community-parser.ts for CLI parsing (supports npub, nprofile, ncommunity:// format, NIP-05, hex) - Add CommunityViewer for viewing community details - Register 'community' command in man pages Key features: - Communities use profile metadata (kind 0) for name/picture - Content sections with badge-based access control - Infrastructure display (relays, blossom servers, mints) - Query community content by kind via REQ viewer
This commit is contained in:
530
src/components/CommunityViewer.tsx
Normal file
530
src/components/CommunityViewer.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { useEffect } from "react";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useEventStore, use$ } from "applesauce-react/hooks";
|
||||
import { addressLoader } from "@/services/loaders";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
getCommunityRelays,
|
||||
getCommunityDescription,
|
||||
getCommunityContentSections,
|
||||
getCommunityBlossomServers,
|
||||
getCommunityMints,
|
||||
getCommunityLocation,
|
||||
getCommunityGeohash,
|
||||
getCommunityTos,
|
||||
getCommunityBadgeRequirements,
|
||||
type ContentSection,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import {
|
||||
Users2,
|
||||
Server,
|
||||
MapPin,
|
||||
Layers,
|
||||
Award,
|
||||
FileText,
|
||||
Flower2,
|
||||
Coins,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { Subscription } from "rxjs";
|
||||
|
||||
export interface CommunityViewerProps {
|
||||
pubkey: string;
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
// Kind number for Communikeys Community event
|
||||
const COMMUNIKEY_KIND = 10222;
|
||||
|
||||
/**
|
||||
* Content Section Card Component for displaying content sections
|
||||
*/
|
||||
function ContentSectionCard({
|
||||
section,
|
||||
communityPubkey,
|
||||
}: {
|
||||
section: ContentSection;
|
||||
communityPubkey: string;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
const handleQueryKind = (kind: number) => {
|
||||
// Open a REQ window to query this kind for the community
|
||||
addWindow("req", {
|
||||
filter: {
|
||||
kinds: [kind],
|
||||
"#h": [communityPubkey],
|
||||
limit: 50,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 flex flex-col gap-3">
|
||||
<h3 className="font-semibold text-base flex items-center gap-2">
|
||||
<Layers className="size-4 text-muted-foreground" />
|
||||
{section.name}
|
||||
</h3>
|
||||
|
||||
{/* Event Kinds */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Content Types</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{section.kinds.map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
onClick={() => handleQueryKind(kind)}
|
||||
className="px-2 py-0.5 text-xs bg-muted hover:bg-muted/80 rounded font-mono transition-colors flex items-center gap-1"
|
||||
title={`Query kind ${kind} events`}
|
||||
>
|
||||
{kind}
|
||||
<Search className="size-2.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge Requirements */}
|
||||
{section.badgePointers.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Award className="size-3" />
|
||||
Required Badges
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{section.badgePointers.map((pointer, idx) => (
|
||||
<code
|
||||
key={idx}
|
||||
className="text-xs bg-muted rounded p-1 font-mono truncate"
|
||||
title={pointer}
|
||||
>
|
||||
{pointer.length > 50
|
||||
? `${pointer.slice(0, 25)}...${pointer.slice(-20)}`
|
||||
: pointer}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CommunityViewer - Detailed view for a Communikeys community (kind 10222)
|
||||
* Shows community information derived from profile + kind 10222 event
|
||||
*/
|
||||
export function CommunityViewer({ pubkey, relays }: CommunityViewerProps) {
|
||||
const { state, addWindow } = useGrimoire();
|
||||
const accountPubkey = state.activeAccount?.pubkey;
|
||||
const eventStore = useEventStore();
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
// Resolve $me alias
|
||||
const resolvedPubkey = pubkey === "$me" ? accountPubkey : pubkey;
|
||||
|
||||
// Fetch profile metadata (community name, picture, etc.)
|
||||
const profile = useProfile(resolvedPubkey);
|
||||
const displayName = getDisplayName(resolvedPubkey ?? "", profile);
|
||||
|
||||
// Fetch kind 10222 community event
|
||||
useEffect(() => {
|
||||
let subscription: Subscription | null = null;
|
||||
if (!resolvedPubkey) return;
|
||||
|
||||
// Fetch the community event from network
|
||||
subscription = addressLoader({
|
||||
kind: COMMUNIKEY_KIND,
|
||||
pubkey: resolvedPubkey,
|
||||
identifier: "",
|
||||
relays,
|
||||
}).subscribe({
|
||||
error: (err) => {
|
||||
console.debug(
|
||||
`[CommunityViewer] Failed to fetch community event for ${resolvedPubkey.slice(0, 8)}:`,
|
||||
err,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [resolvedPubkey, eventStore, relays]);
|
||||
|
||||
// Get community event (kind 10222) from EventStore
|
||||
const communityEvent = use$(
|
||||
() =>
|
||||
resolvedPubkey
|
||||
? eventStore.replaceable(COMMUNIKEY_KIND, resolvedPubkey, "")
|
||||
: undefined,
|
||||
[eventStore, resolvedPubkey],
|
||||
);
|
||||
|
||||
// Extract community metadata
|
||||
const communityRelays = communityEvent
|
||||
? getCommunityRelays(communityEvent)
|
||||
: [];
|
||||
const description = communityEvent
|
||||
? getCommunityDescription(communityEvent)
|
||||
: undefined;
|
||||
const contentSections = communityEvent
|
||||
? getCommunityContentSections(communityEvent)
|
||||
: [];
|
||||
const blossomServers = communityEvent
|
||||
? getCommunityBlossomServers(communityEvent)
|
||||
: [];
|
||||
const mints = communityEvent ? getCommunityMints(communityEvent) : [];
|
||||
const location = communityEvent
|
||||
? getCommunityLocation(communityEvent)
|
||||
: undefined;
|
||||
const geohash = communityEvent
|
||||
? getCommunityGeohash(communityEvent)
|
||||
: undefined;
|
||||
const tos = communityEvent ? getCommunityTos(communityEvent) : undefined;
|
||||
const badgeRequirements = communityEvent
|
||||
? getCommunityBadgeRequirements(communityEvent)
|
||||
: [];
|
||||
|
||||
// Generate npub for display
|
||||
const npub = resolvedPubkey ? nip19.npubEncode(resolvedPubkey) : "";
|
||||
|
||||
const handleCopyNpub = () => {
|
||||
copy(npub);
|
||||
};
|
||||
|
||||
const handleOpenProfile = () => {
|
||||
if (resolvedPubkey) {
|
||||
addWindow("profile", { pubkey: resolvedPubkey });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChat = (kind: number) => {
|
||||
if (!resolvedPubkey) return;
|
||||
|
||||
// For kind 9 (chat) or kind 11 (forum), we can query community content
|
||||
addWindow("req", {
|
||||
filter: {
|
||||
kinds: [kind],
|
||||
"#h": [resolvedPubkey],
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenRelayViewer = (url: string) => {
|
||||
addWindow("relay", { url });
|
||||
};
|
||||
|
||||
// Handle $me alias without account
|
||||
if (pubkey === "$me" && !accountPubkey) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Users2 className="size-12 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
||||
<p className="text-sm max-w-md">
|
||||
The <code className="bg-muted px-1.5 py-0.5">$me</code> alias
|
||||
requires an active account. Please log in to view your community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!resolvedPubkey) {
|
||||
return (
|
||||
<div className="p-4 text-muted-foreground">Invalid community pubkey.</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (!profile && !communityEvent) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading community...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No community event found
|
||||
const noCommunityEvent = profile && !communityEvent;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Compact Header */}
|
||||
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={handleCopyNpub}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
|
||||
title={npub}
|
||||
aria-label="Copy community ID"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 flex-shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-3 flex-shrink-0" />
|
||||
)}
|
||||
<code className="truncate">
|
||||
{npub.slice(0, 16)}...{npub.slice(-8)}
|
||||
</code>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Users2 className="size-3" />
|
||||
<span>Community</span>
|
||||
</div>
|
||||
|
||||
{communityRelays.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Server className="size-3" />
|
||||
<span>{communityRelays.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Community Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-3xl mx-auto flex flex-col gap-6">
|
||||
{/* Header Section */}
|
||||
<div className="flex gap-4">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-20 md:size-24 rounded-xl object-cover flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-20 md:size-24 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Users2 className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 flex-1 min-w-0">
|
||||
<h1 className="text-2xl md:text-3xl font-bold">{displayName}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm md:text-base">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{location && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MapPin className="size-4" />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Info */}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Admin:</span>
|
||||
<UserName pubkey={resolvedPubkey} />
|
||||
<button
|
||||
onClick={handleOpenProfile}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="View Profile"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* No Community Event Warning */}
|
||||
{noCommunityEvent && (
|
||||
<div className="border border-yellow-500/30 bg-yellow-500/10 rounded-lg p-4 flex items-start gap-3">
|
||||
<Users2 className="size-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-yellow-500">
|
||||
No Community Found
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This pubkey does not have a Communikeys community event (kind{" "}
|
||||
{COMMUNIKEY_KIND}). The profile exists but no community has
|
||||
been created for it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Sections */}
|
||||
{contentSections.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Layers className="size-5" />
|
||||
Content Sections
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{contentSections.map((section, idx) => (
|
||||
<ContentSectionCard
|
||||
key={idx}
|
||||
section={section}
|
||||
communityPubkey={resolvedPubkey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions for Chat/Forum */}
|
||||
{contentSections.some((s) => s.kinds.includes(9)) && (
|
||||
<button
|
||||
onClick={() => handleOpenChat(9)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline w-fit"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
View Community Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infrastructure */}
|
||||
{(communityRelays.length > 0 ||
|
||||
blossomServers.length > 0 ||
|
||||
mints.length > 0) && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">Infrastructure</h2>
|
||||
|
||||
{/* Relays */}
|
||||
{communityRelays.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Server className="size-3.5" />
|
||||
Relays
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{communityRelays.map((relay, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenRelayViewer(relay)}
|
||||
className="text-xs bg-muted hover:bg-muted/80 rounded px-2 py-1 font-mono truncate transition-colors flex items-center gap-1"
|
||||
>
|
||||
{relay}
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
{idx === 0 && (
|
||||
<span className="text-xs text-primary">(main)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blossom Servers */}
|
||||
{blossomServers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Flower2 className="size-3.5" />
|
||||
Blossom Servers
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{blossomServers.map((server, idx) => (
|
||||
<code
|
||||
key={idx}
|
||||
className="text-xs bg-muted rounded px-2 py-1 font-mono truncate"
|
||||
>
|
||||
{server}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mints */}
|
||||
{mints.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Coins className="size-3.5" />
|
||||
Ecash Mints
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{mints.map((mint, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono truncate">
|
||||
{mint.url}
|
||||
</code>
|
||||
{mint.type && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({mint.type})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge Requirements */}
|
||||
{badgeRequirements.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Award className="size-5" />
|
||||
Required Badges
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users need one of these badges to publish content:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badgeRequirements.map((badge, idx) => (
|
||||
<code
|
||||
key={idx}
|
||||
className="text-xs bg-muted rounded px-2 py-1 font-mono truncate"
|
||||
title={badge}
|
||||
>
|
||||
{badge.length > 40
|
||||
? `${badge.slice(0, 20)}...${badge.slice(-15)}`
|
||||
: badge}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terms of Service */}
|
||||
{tos && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<FileText className="size-5" />
|
||||
Terms of Service
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono truncate">
|
||||
{tos.reference}
|
||||
</code>
|
||||
{tos.relay && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
via {tos.relay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geohash (technical detail) */}
|
||||
{geohash && (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<span className="text-muted-foreground">Geohash</span>
|
||||
<code className="font-mono">{geohash}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,9 @@ const ZapWindow = lazy(() =>
|
||||
import("./ZapWindow").then((m) => ({ default: m.ZapWindow })),
|
||||
);
|
||||
const CountViewer = lazy(() => import("./CountViewer"));
|
||||
const CommunityViewer = lazy(() =>
|
||||
import("./CommunityViewer").then((m) => ({ default: m.CommunityViewer })),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -241,6 +244,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "community":
|
||||
content = (
|
||||
<CommunityViewer
|
||||
pubkey={window.props.pubkey}
|
||||
relays={window.props.relays}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
344
src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
Normal file
344
src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import {
|
||||
getCommunityRelays,
|
||||
getCommunityDescription,
|
||||
getCommunityContentSections,
|
||||
getCommunityBlossomServers,
|
||||
getCommunityMints,
|
||||
getCommunityLocation,
|
||||
getCommunityGeohash,
|
||||
getCommunityTos,
|
||||
getCommunityBadgeRequirements,
|
||||
type ContentSection,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { UserName } from "../UserName";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
Users2,
|
||||
Server,
|
||||
MapPin,
|
||||
Layers,
|
||||
Award,
|
||||
FileText,
|
||||
Flower2,
|
||||
Coins,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
|
||||
interface CommunikeyDetailRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Section Card Component
|
||||
*/
|
||||
function ContentSectionCard({ section }: { section: ContentSection }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 flex flex-col gap-2">
|
||||
<h3 className="font-semibold text-base flex items-center gap-2">
|
||||
<Layers className="size-4 text-muted-foreground" />
|
||||
{section.name}
|
||||
</h3>
|
||||
|
||||
{/* Event Kinds */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Allowed Kinds</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{section.kinds.map((kind) => (
|
||||
<code
|
||||
key={kind}
|
||||
className="px-2 py-0.5 text-xs bg-muted rounded font-mono"
|
||||
>
|
||||
{kind}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge Requirements */}
|
||||
{section.badgePointers.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Award className="size-3" />
|
||||
Required Badges (any)
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{section.badgePointers.map((pointer, idx) => (
|
||||
<code
|
||||
key={idx}
|
||||
className="text-xs bg-muted rounded p-1 font-mono truncate"
|
||||
title={pointer}
|
||||
>
|
||||
{pointer}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Communikeys events (kind 10222)
|
||||
* Shows full community information including all content sections,
|
||||
* infrastructure (relays, blossom servers, mints), and metadata
|
||||
*/
|
||||
export function CommunikeyDetailRenderer({
|
||||
event,
|
||||
}: CommunikeyDetailRendererProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
// Community's identity comes from the pubkey's profile
|
||||
const profile = useProfile(event.pubkey);
|
||||
const displayName = getDisplayName(event.pubkey, profile);
|
||||
|
||||
// Extract all community metadata
|
||||
const relays = getCommunityRelays(event);
|
||||
const description = getCommunityDescription(event);
|
||||
const contentSections = getCommunityContentSections(event);
|
||||
const blossomServers = getCommunityBlossomServers(event);
|
||||
const mints = getCommunityMints(event);
|
||||
const location = getCommunityLocation(event);
|
||||
const geohash = getCommunityGeohash(event);
|
||||
const tos = getCommunityTos(event);
|
||||
const badgeRequirements = getCommunityBadgeRequirements(event);
|
||||
|
||||
// Generate ncommunity identifier
|
||||
const npub = nip19.npubEncode(event.pubkey);
|
||||
|
||||
const handleCopyNpub = () => {
|
||||
copy(npub);
|
||||
};
|
||||
|
||||
const handleOpenProfile = () => {
|
||||
addWindow("profile", { pubkey: event.pubkey });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
|
||||
{/* Header Section */}
|
||||
<div className="flex gap-4">
|
||||
{/* Community Avatar */}
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-24 md:size-32 rounded-xl object-cover flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-24 md:size-32 rounded-xl bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Users2 className="size-12 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Community Title & Description */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-w-0">
|
||||
<h1 className="text-2xl md:text-3xl font-bold flex items-center gap-2">
|
||||
<Users2 className="size-6 md:size-8 text-muted-foreground" />
|
||||
{displayName}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm md:text-base">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
{/* Admin/Owner */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground flex items-center gap-1">
|
||||
Admin
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserName pubkey={event.pubkey} />
|
||||
<button
|
||||
onClick={handleOpenProfile}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Open Profile"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Community ID */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Community ID</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="font-mono text-xs truncate flex-1 cursor-pointer hover:text-primary"
|
||||
title={npub}
|
||||
onClick={handleCopyNpub}
|
||||
>
|
||||
{npub.slice(0, 20)}...{npub.slice(-8)}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{copied ? "Copied!" : "Click to copy"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground flex items-center gap-1">
|
||||
<MapPin className="size-3.5" />
|
||||
Location
|
||||
</h3>
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geohash */}
|
||||
{geohash && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Geohash</h3>
|
||||
<code className="font-mono text-xs">{geohash}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Sections */}
|
||||
{contentSections.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Layers className="size-5" />
|
||||
Content Sections
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{contentSections.map((section, idx) => (
|
||||
<ContentSectionCard key={idx} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infrastructure Section */}
|
||||
{(relays.length > 0 || blossomServers.length > 0 || mints.length > 0) && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold">Infrastructure</h2>
|
||||
|
||||
{/* Relays */}
|
||||
{relays.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Server className="size-3.5" />
|
||||
Relays ({relays.length})
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{relays.map((relay, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono truncate">
|
||||
{relay}
|
||||
</code>
|
||||
{idx === 0 && (
|
||||
<span className="text-xs text-primary">(main)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blossom Servers */}
|
||||
{blossomServers.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Flower2 className="size-3.5" />
|
||||
Blossom Servers ({blossomServers.length})
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{blossomServers.map((server, idx) => (
|
||||
<code
|
||||
key={idx}
|
||||
className="text-xs bg-muted rounded px-2 py-1 font-mono truncate"
|
||||
>
|
||||
{server}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mints */}
|
||||
{mints.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Coins className="size-3.5" />
|
||||
Ecash Mints ({mints.length})
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{mints.map((mint, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono truncate">
|
||||
{mint.url}
|
||||
</code>
|
||||
{mint.type && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({mint.type})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge Requirements Summary */}
|
||||
{badgeRequirements.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Award className="size-5" />
|
||||
Required Badges
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users need one of these badges to publish content in this community:
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{badgeRequirements.map((badge, idx) => (
|
||||
<code
|
||||
key={idx}
|
||||
className="text-xs bg-muted rounded px-2 py-1 font-mono truncate"
|
||||
title={badge}
|
||||
>
|
||||
{badge}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terms of Service */}
|
||||
{tos && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<FileText className="size-5" />
|
||||
Terms of Service
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted rounded px-2 py-1 font-mono truncate">
|
||||
{tos.reference}
|
||||
</code>
|
||||
{tos.relay && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
via {tos.relay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal file
114
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import {
|
||||
getCommunityRelays,
|
||||
getCommunityDescription,
|
||||
getCommunityContentSections,
|
||||
getCommunityLocation,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Users2, Server, MapPin, Layers } from "lucide-react";
|
||||
|
||||
interface CommunikeyRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for Communikeys events (kind 10222)
|
||||
* Displays community info with name/image from profile metadata
|
||||
* and community-specific data from the event tags
|
||||
*/
|
||||
export function CommunikeyRenderer({ event }: CommunikeyRendererProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Community's identity comes from the pubkey's profile
|
||||
const profile = useProfile(event.pubkey);
|
||||
const displayName = getDisplayName(event.pubkey, profile);
|
||||
|
||||
// Extract community metadata from event tags
|
||||
const relays = getCommunityRelays(event);
|
||||
const description = getCommunityDescription(event);
|
||||
const contentSections = getCommunityContentSections(event);
|
||||
const location = getCommunityLocation(event);
|
||||
|
||||
const handleOpenCommunity = () => {
|
||||
addWindow("community", {
|
||||
pubkey: event.pubkey,
|
||||
relays: relays.length > 0 ? relays : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex gap-3">
|
||||
{/* Community Avatar */}
|
||||
{profile?.picture && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-12 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
{/* Community Name */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-base flex items-center gap-2"
|
||||
>
|
||||
<Users2 className="size-4 text-muted-foreground" />
|
||||
{displayName}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{/* Relay Count */}
|
||||
{relays.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="size-3" />
|
||||
{relays.length} {relays.length === 1 ? "relay" : "relays"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Content Sections */}
|
||||
{contentSections.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Layers className="size-3" />
|
||||
{contentSections.length}{" "}
|
||||
{contentSections.length === 1 ? "section" : "sections"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Community Button */}
|
||||
<button
|
||||
onClick={handleOpenCommunity}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1 w-fit mt-1"
|
||||
>
|
||||
<Users2 className="size-3" />
|
||||
View Community
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
328
src/components/nostr/kinds/TargetedPublicationRenderer.tsx
Normal file
328
src/components/nostr/kinds/TargetedPublicationRenderer.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import {
|
||||
getTargetedPublicationEventId,
|
||||
getTargetedPublicationAddress,
|
||||
getTargetedPublicationKind,
|
||||
getTargetedCommunities,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { BaseEventContainer } from "./BaseEventRenderer";
|
||||
import { UserName } from "../UserName";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { Share2, Users2, FileText, ExternalLink } from "lucide-react";
|
||||
import { KindRenderer } from "./index";
|
||||
|
||||
interface TargetedPublicationRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for Targeted Publication events (kind 30222)
|
||||
* Shows the publication being shared to communities
|
||||
*/
|
||||
export function TargetedPublicationRenderer({
|
||||
event,
|
||||
}: TargetedPublicationRendererProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get the original publication reference
|
||||
const eventId = getTargetedPublicationEventId(event);
|
||||
const address = getTargetedPublicationAddress(event);
|
||||
const originalKind = getTargetedPublicationKind(event);
|
||||
const targetedCommunities = getTargetedCommunities(event);
|
||||
|
||||
// Create pointer for the original event
|
||||
const pointer = eventId
|
||||
? { id: eventId }
|
||||
: address
|
||||
? (() => {
|
||||
const [kind, pubkey, identifier] = address.split(":");
|
||||
return {
|
||||
kind: parseInt(kind, 10),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
// Fetch the original publication
|
||||
const originalEvent = useNostrEvent(pointer);
|
||||
|
||||
const handleOpenOriginal = () => {
|
||||
if (pointer) {
|
||||
addWindow("open", { pointer });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCommunity = (pubkey: string, relays?: string[]) => {
|
||||
addWindow("community", { pubkey, relays });
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Share2 className="size-4" />
|
||||
<span>Shared to {targetedCommunities.length} communities</span>
|
||||
</div>
|
||||
|
||||
{/* Target Communities */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{targetedCommunities.map((community, idx) => (
|
||||
<CommunityChip
|
||||
key={idx}
|
||||
pubkey={community.pubkey}
|
||||
relay={community.relay}
|
||||
onClick={() =>
|
||||
handleOpenCommunity(
|
||||
community.pubkey,
|
||||
community.relay ? [community.relay] : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Original Publication Preview */}
|
||||
{originalEvent ? (
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-muted/30">
|
||||
<KindRenderer event={originalEvent} depth={1} />
|
||||
</div>
|
||||
) : pointer ? (
|
||||
<div className="border border-border rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
<span>
|
||||
{originalKind ? `Kind ${originalKind}` : "Loading..."}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenOriginal}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small chip component for displaying a target community
|
||||
*/
|
||||
function CommunityChip({
|
||||
pubkey,
|
||||
relay,
|
||||
onClick,
|
||||
}: {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const profile = useProfile(pubkey);
|
||||
const displayName = getDisplayName(pubkey, profile);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs bg-muted hover:bg-muted/80 rounded-full transition-colors"
|
||||
title={relay ? `via ${relay}` : undefined}
|
||||
>
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-4 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Users2 className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium truncate max-w-[120px]">{displayName}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Targeted Publication events (kind 30222)
|
||||
*/
|
||||
export function TargetedPublicationDetailRenderer({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get the original publication reference
|
||||
const eventId = getTargetedPublicationEventId(event);
|
||||
const address = getTargetedPublicationAddress(event);
|
||||
const originalKind = getTargetedPublicationKind(event);
|
||||
const targetedCommunities = getTargetedCommunities(event);
|
||||
|
||||
// Create pointer for the original event
|
||||
const pointer = eventId
|
||||
? { id: eventId }
|
||||
: address
|
||||
? (() => {
|
||||
const [kind, pubkey, identifier] = address.split(":");
|
||||
return {
|
||||
kind: parseInt(kind, 10),
|
||||
pubkey,
|
||||
identifier,
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
// Fetch the original publication
|
||||
const originalEvent = useNostrEvent(pointer);
|
||||
|
||||
const handleOpenOriginal = () => {
|
||||
if (pointer) {
|
||||
addWindow("open", { pointer });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCommunity = (pubkey: string, relays?: string[]) => {
|
||||
addWindow("community", { pubkey, relays });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Share2 className="size-6" />
|
||||
Targeted Publication
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Published by <UserName pubkey={event.pubkey} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Target Communities */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Users2 className="size-5" />
|
||||
Target Communities ({targetedCommunities.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{targetedCommunities.map((community, idx) => (
|
||||
<CommunityTargetCard
|
||||
key={idx}
|
||||
pubkey={community.pubkey}
|
||||
relay={community.relay}
|
||||
onClick={() =>
|
||||
handleOpenCommunity(
|
||||
community.pubkey,
|
||||
community.relay ? [community.relay] : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original Publication */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<FileText className="size-5" />
|
||||
Original Publication
|
||||
</h2>
|
||||
{pointer && (
|
||||
<button
|
||||
onClick={handleOpenOriginal}
|
||||
className="text-sm text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
Open in new window
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{originalEvent ? (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<KindRenderer event={originalEvent} depth={0} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg p-4 text-center text-muted-foreground">
|
||||
{originalKind
|
||||
? `Loading kind ${originalKind} event...`
|
||||
: "Loading..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{eventId && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Referenced Event ID</h3>
|
||||
<code className="font-mono text-xs truncate">{eventId}</code>
|
||||
</div>
|
||||
)}
|
||||
{address && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Referenced Address</h3>
|
||||
<code className="font-mono text-xs truncate">{address}</code>
|
||||
</div>
|
||||
)}
|
||||
{originalKind !== undefined && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">Original Kind</h3>
|
||||
<span>{originalKind}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component for displaying a target community in detail view
|
||||
*/
|
||||
function CommunityTargetCard({
|
||||
pubkey,
|
||||
relay,
|
||||
onClick,
|
||||
}: {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const profile = useProfile(pubkey);
|
||||
const displayName = getDisplayName(pubkey, profile);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-10 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-10 rounded-lg bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Users2 className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="font-semibold truncate">{displayName}</span>
|
||||
{relay && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
via {relay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="size-4 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -148,6 +148,12 @@ import { BadgeAwardRenderer } from "./BadgeAwardRenderer";
|
||||
import { BadgeAwardDetailRenderer } from "./BadgeAwardDetailRenderer";
|
||||
import { ProfileBadgesRenderer } from "./ProfileBadgesRenderer";
|
||||
import { ProfileBadgesDetailRenderer } from "./ProfileBadgesDetailRenderer";
|
||||
import { CommunikeyRenderer } from "./CommunikeyRenderer";
|
||||
import { CommunikeyDetailRenderer } from "./CommunikeyDetailRenderer";
|
||||
import {
|
||||
TargetedPublicationRenderer,
|
||||
TargetedPublicationDetailRenderer,
|
||||
} from "./TargetedPublicationRenderer";
|
||||
|
||||
/**
|
||||
* Registry of kind-specific renderers
|
||||
@@ -198,6 +204,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
|
||||
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
|
||||
10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
|
||||
10222: CommunikeyRenderer, // Communikey Community (Communikeys)
|
||||
10317: Kind10317Renderer, // User Grasp List (NIP-34)
|
||||
13534: RelayMembersRenderer, // Relay Members (NIP-43)
|
||||
30000: FollowSetRenderer, // Follow Sets (NIP-51)
|
||||
@@ -211,6 +218,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
30009: BadgeDefinitionRenderer, // Badge (NIP-58)
|
||||
30015: InterestSetRenderer, // Interest Sets (NIP-51)
|
||||
30023: Kind30023Renderer, // Long-form Article
|
||||
30222: TargetedPublicationRenderer, // Targeted Publication (Communikeys)
|
||||
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
|
||||
30063: ZapstoreReleaseRenderer, // Zapstore App Release
|
||||
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
|
||||
@@ -296,6 +304,7 @@ const detailRenderers: Record<
|
||||
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)
|
||||
10222: CommunikeyDetailRenderer, // Communikey Community Detail (Communikeys)
|
||||
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
|
||||
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
|
||||
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
|
||||
@@ -308,6 +317,7 @@ const detailRenderers: Record<
|
||||
30009: BadgeDefinitionDetailRenderer, // Badge Detail (NIP-58)
|
||||
30015: InterestSetDetailRenderer, // Interest Sets Detail (NIP-51)
|
||||
30023: Kind30023DetailRenderer, // Long-form Article Detail
|
||||
30222: TargetedPublicationDetailRenderer, // Targeted Publication Detail (Communikeys)
|
||||
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
|
||||
30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail
|
||||
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
|
||||
|
||||
340
src/lib/communikeys-helpers.ts
Normal file
340
src/lib/communikeys-helpers.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Communikeys Helper Functions
|
||||
* Utility functions for parsing Communikeys events (kind 10222, kind 30222)
|
||||
*
|
||||
* Based on the Communikeys standard:
|
||||
* - Kind 10222: Community Creation Event (replaceable)
|
||||
* - Kind 30222: Targeted Publication Event (parameterized replaceable)
|
||||
*
|
||||
* Kind numbers:
|
||||
* - 10222 is in the replaceable event range (10000-19999)
|
||||
* - 30222 is in the parameterized replaceable event range (30000-39999)
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Community Event Helpers (Kind 10222)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Content section within a community definition
|
||||
* Groups related event kinds with optional badge requirements
|
||||
*/
|
||||
export interface ContentSection {
|
||||
name: string;
|
||||
kinds: number[];
|
||||
badgePointers: string[]; // a-tag references to badge definitions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all relay URLs from a community event
|
||||
* First relay in the array is considered the main relay
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of relay URLs
|
||||
*/
|
||||
export function getCommunityRelays(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "r").map((t) => t[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main relay URL for a community
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Main relay URL or undefined
|
||||
*/
|
||||
export function getCommunityMainRelay(event: NostrEvent): string | undefined {
|
||||
const relayTag = event.tags.find((t) => t[0] === "r");
|
||||
return relayTag ? relayTag[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blossom server URLs from a community event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of blossom server URLs
|
||||
*/
|
||||
export function getCommunityBlossomServers(event: NostrEvent): string[] {
|
||||
return event.tags.filter((t) => t[0] === "blossom").map((t) => t[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ecash mint URLs from a community event
|
||||
* Returns objects with URL and type (e.g., "cashu")
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of mint objects
|
||||
*/
|
||||
export function getCommunityMints(
|
||||
event: NostrEvent,
|
||||
): Array<{ url: string; type?: string }> {
|
||||
return event.tags
|
||||
.filter((t) => t[0] === "mint")
|
||||
.map((t) => ({
|
||||
url: t[1],
|
||||
type: t[2], // e.g., "cashu"
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the community description
|
||||
* Falls back to event content if no description tag present
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Description text or undefined
|
||||
*/
|
||||
export function getCommunityDescription(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "description") || event.content || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the community location
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Location string or undefined
|
||||
*/
|
||||
export function getCommunityLocation(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "location");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the community geohash
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Geohash string or undefined
|
||||
*/
|
||||
export function getCommunityGeohash(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "g");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the terms of service reference for a community
|
||||
* Returns the event ID/address and optional relay hint
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns TOS reference object or undefined
|
||||
*/
|
||||
export function getCommunityTos(
|
||||
event: NostrEvent,
|
||||
): { reference: string; relay?: string } | undefined {
|
||||
const tosTag = event.tags.find((t) => t[0] === "tos");
|
||||
if (!tosTag || !tosTag[1]) return undefined;
|
||||
return {
|
||||
reference: tosTag[1],
|
||||
relay: tosTag[2],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all content sections from a community event
|
||||
* Content sections define what types of events the community supports
|
||||
* and who can publish them (via badge requirements)
|
||||
*
|
||||
* Tags are sequential: ["content", "Chat"], ["k", "9"], ["a", "30009:..."]
|
||||
* Each content tag starts a new section; k and a tags belong to the preceding content
|
||||
*
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of content sections
|
||||
*/
|
||||
export function getCommunityContentSections(
|
||||
event: NostrEvent,
|
||||
): ContentSection[] {
|
||||
const sections: ContentSection[] = [];
|
||||
let currentSection: ContentSection | null = null;
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "content" && tag[1]) {
|
||||
// Start a new content section
|
||||
if (currentSection) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
currentSection = {
|
||||
name: tag[1],
|
||||
kinds: [],
|
||||
badgePointers: [],
|
||||
};
|
||||
} else if (currentSection) {
|
||||
if (tag[0] === "k" && tag[1]) {
|
||||
// Add kind to current section
|
||||
const kind = parseInt(tag[1], 10);
|
||||
if (!isNaN(kind)) {
|
||||
currentSection.kinds.push(kind);
|
||||
}
|
||||
} else if (tag[0] === "a" && tag[1]) {
|
||||
// Add badge requirement to current section
|
||||
currentSection.badgePointers.push(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique event kinds supported by a community
|
||||
* Aggregates kinds from all content sections
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of unique kind numbers
|
||||
*/
|
||||
export function getCommunitySupportedKinds(event: NostrEvent): number[] {
|
||||
const sections = getCommunityContentSections(event);
|
||||
const kinds = new Set<number>();
|
||||
for (const section of sections) {
|
||||
for (const kind of section.kinds) {
|
||||
kinds.add(kind);
|
||||
}
|
||||
}
|
||||
return Array.from(kinds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique badge pointers required by a community
|
||||
* Aggregates badge requirements from all content sections
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of unique badge address pointers (a-tag format)
|
||||
*/
|
||||
export function getCommunityBadgeRequirements(event: NostrEvent): string[] {
|
||||
const sections = getCommunityContentSections(event);
|
||||
const badges = new Set<string>();
|
||||
for (const section of sections) {
|
||||
for (const badge of section.badgePointers) {
|
||||
badges.add(badge);
|
||||
}
|
||||
}
|
||||
return Array.from(badges);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Targeted Publication Event Helpers (Kind 30222)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the d-tag identifier for a targeted publication
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Identifier string or undefined
|
||||
*/
|
||||
export function getTargetedPublicationIdentifier(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "d");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the referenced event ID from a targeted publication
|
||||
* Uses the e-tag for non-addressable events
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Event ID or undefined
|
||||
*/
|
||||
export function getTargetedPublicationEventId(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "e");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the referenced address pointer from a targeted publication
|
||||
* Uses the a-tag for addressable events
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Address pointer string (kind:pubkey:d-tag) or undefined
|
||||
*/
|
||||
export function getTargetedPublicationAddress(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "a");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the kind of the original publication being targeted
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Kind number or undefined
|
||||
*/
|
||||
export function getTargetedPublicationKind(
|
||||
event: NostrEvent,
|
||||
): number | undefined {
|
||||
const kTag = getTagValue(event, "k");
|
||||
if (!kTag) return undefined;
|
||||
const kind = parseInt(kTag, 10);
|
||||
return isNaN(kind) ? undefined : kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Community target within a targeted publication
|
||||
* Contains the community pubkey and optional main relay
|
||||
*/
|
||||
export interface CommunityTarget {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all targeted communities from a targeted publication
|
||||
* Parses alternating p and r tags to build community targets
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Array of community targets
|
||||
*/
|
||||
export function getTargetedCommunities(event: NostrEvent): CommunityTarget[] {
|
||||
const communities: CommunityTarget[] = [];
|
||||
|
||||
// Parse p and r tags sequentially
|
||||
// Each p tag is followed by its corresponding r tag
|
||||
const pTags = event.tags.filter((t) => t[0] === "p");
|
||||
const rTags = event.tags.filter((t) => t[0] === "r");
|
||||
|
||||
for (let i = 0; i < pTags.length; i++) {
|
||||
const pubkey = pTags[i][1];
|
||||
if (pubkey) {
|
||||
communities.push({
|
||||
pubkey,
|
||||
relay: rTags[i]?.[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return communities;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Community-Exclusive Event Helpers (Kind 9, Kind 11)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the community pubkey from an exclusive event (kind 9, 11)
|
||||
* These events use an h-tag to reference their community
|
||||
* @param event Chat message (kind 9) or Forum post (kind 11)
|
||||
* @returns Community pubkey or undefined
|
||||
*/
|
||||
export function getExclusiveEventCommunity(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "h");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if an event is a Communikeys community event
|
||||
* @param event Nostr event
|
||||
* @returns True if kind 10222
|
||||
*/
|
||||
export function isCommunityEvent(event: NostrEvent): boolean {
|
||||
return event.kind === 10222;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is a Communikeys targeted publication event
|
||||
* @param event Nostr event
|
||||
* @returns True if kind 30222
|
||||
*/
|
||||
export function isTargetedPublicationEvent(event: NostrEvent): boolean {
|
||||
return event.kind === 30222;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a chat message or forum post belongs to a community
|
||||
* @param event Chat message (kind 9) or Forum post (kind 11)
|
||||
* @returns True if has h-tag (community reference)
|
||||
*/
|
||||
export function isExclusiveCommunityEvent(event: NostrEvent): boolean {
|
||||
return (event.kind === 9 || event.kind === 11) && !!getTagValue(event, "h");
|
||||
}
|
||||
172
src/lib/community-parser.ts
Normal file
172
src/lib/community-parser.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { isNip05, resolveNip05 } from "./nip05";
|
||||
import { isValidHexPubkey, normalizeHex } from "./nostr-validation";
|
||||
|
||||
export interface ParsedCommunityCommand {
|
||||
/** The community's pubkey (also serves as unique identifier) */
|
||||
pubkey: string;
|
||||
/** Relay hints for fetching the community's kind 10222 event */
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the ncommunity:// format
|
||||
* Format: ncommunity://<pubkey>?relay=<url-encoded-relay-1>&relay=<url-encoded-relay-2>
|
||||
*
|
||||
* @param identifier ncommunity:// string
|
||||
* @returns Parsed pubkey and relays or null if not valid
|
||||
*/
|
||||
function parseNcommunityFormat(
|
||||
identifier: string,
|
||||
): { pubkey: string; relays: string[] } | null {
|
||||
if (!identifier.startsWith("ncommunity://")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove the ncommunity:// prefix
|
||||
const rest = identifier.slice("ncommunity://".length);
|
||||
|
||||
// Split pubkey from query params
|
||||
const [pubkey, queryString] = rest.split("?");
|
||||
|
||||
if (!pubkey || !isValidHexPubkey(pubkey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse relay query params
|
||||
const relays: string[] = [];
|
||||
if (queryString) {
|
||||
const params = new URLSearchParams(queryString);
|
||||
const relayParams = params.getAll("relay");
|
||||
for (const relay of relayParams) {
|
||||
try {
|
||||
relays.push(decodeURIComponent(relay));
|
||||
} catch {
|
||||
// Skip invalid URL-encoded relay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey: normalizeHex(pubkey),
|
||||
relays,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse COMMUNITY command arguments into a community identifier
|
||||
*
|
||||
* Supports:
|
||||
* - npub1... (bech32 npub - community pubkey)
|
||||
* - nprofile1... (bech32 nprofile with relay hints)
|
||||
* - ncommunity://<pubkey>?relay=... (Communikeys format)
|
||||
* - abc123... (64-char hex pubkey)
|
||||
* - user@domain.com (NIP-05 identifier - resolves to pubkey)
|
||||
* - domain.com (bare domain, resolved as _@domain.com)
|
||||
* - $me (active account alias)
|
||||
*
|
||||
* @param args Command arguments
|
||||
* @param activeAccountPubkey Active account pubkey for $me alias
|
||||
* @returns Parsed community command with pubkey and optional relay hints
|
||||
*/
|
||||
export async function parseCommunityCommand(
|
||||
args: string[],
|
||||
activeAccountPubkey?: string,
|
||||
): Promise<ParsedCommunityCommand> {
|
||||
const identifier = args[0];
|
||||
|
||||
if (!identifier) {
|
||||
throw new Error("Community identifier required");
|
||||
}
|
||||
|
||||
// Handle $me alias (view own community if it exists)
|
||||
if (identifier.toLowerCase() === "$me") {
|
||||
if (!activeAccountPubkey) {
|
||||
throw new Error("No active account. Log in to use $me alias.");
|
||||
}
|
||||
return {
|
||||
pubkey: activeAccountPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
// Try ncommunity:// format first
|
||||
const ncommunityResult = parseNcommunityFormat(identifier);
|
||||
if (ncommunityResult) {
|
||||
return {
|
||||
pubkey: ncommunityResult.pubkey,
|
||||
relays:
|
||||
ncommunityResult.relays.length > 0
|
||||
? ncommunityResult.relays
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Try bech32 decode (npub, nprofile)
|
||||
if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
|
||||
try {
|
||||
const decoded = nip19.decode(identifier);
|
||||
|
||||
if (decoded.type === "npub") {
|
||||
return {
|
||||
pubkey: decoded.data,
|
||||
};
|
||||
}
|
||||
|
||||
if (decoded.type === "nprofile") {
|
||||
return {
|
||||
pubkey: decoded.data.pubkey,
|
||||
relays: decoded.data.relays,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid bech32 identifier: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a hex pubkey
|
||||
if (isValidHexPubkey(identifier)) {
|
||||
return {
|
||||
pubkey: normalizeHex(identifier),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a NIP-05 identifier (user@domain.com or domain.com)
|
||||
if (isNip05(identifier)) {
|
||||
const pubkey = await resolveNip05(identifier);
|
||||
if (!pubkey) {
|
||||
throw new Error(
|
||||
`Failed to resolve NIP-05 identifier: ${identifier}. Please check the identifier and try again.`,
|
||||
);
|
||||
}
|
||||
return { pubkey };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Invalid community identifier. Supported formats: npub1..., nprofile1..., ncommunity://..., hex pubkey, user@domain.com, or domain.com",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a community identifier to ncommunity format
|
||||
*
|
||||
* @param pubkey Community pubkey
|
||||
* @param relays Optional relay hints
|
||||
* @returns ncommunity:// formatted string
|
||||
*/
|
||||
export function encodeNcommunity(pubkey: string, relays?: string[]): string {
|
||||
let result = `ncommunity://${pubkey}`;
|
||||
|
||||
if (relays && relays.length > 0) {
|
||||
const params = new URLSearchParams();
|
||||
for (const relay of relays) {
|
||||
params.append("relay", relay);
|
||||
}
|
||||
result += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export type AppId =
|
||||
| "blossom"
|
||||
| "wallet"
|
||||
| "zap"
|
||||
| "community"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveNip05Batch, resolveDomainDirectoryBatch } from "@/lib/nip05";
|
||||
import { parseChatCommand } from "@/lib/chat-parser";
|
||||
import { parseBlossomCommand } from "@/lib/blossom-parser";
|
||||
import { parseZapCommand } from "@/lib/zap-parser";
|
||||
import { parseCommunityCommand } from "@/lib/community-parser";
|
||||
|
||||
export interface ManPageEntry {
|
||||
name: string;
|
||||
@@ -843,4 +844,31 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
category: "Nostr",
|
||||
defaultProps: {},
|
||||
},
|
||||
community: {
|
||||
name: "community",
|
||||
section: "1",
|
||||
synopsis: "community <identifier>",
|
||||
description:
|
||||
"View a Communikeys community (kind 10222). Communikeys are decentralized communities on Nostr that use existing keypairs and relays. Communities define content sections with badge-based access control, allowing any existing npub to become a community and any publication to be targeted at communities. Unlike NIP-29 groups, Communikeys work on any standard Nostr relay with client-side access control.",
|
||||
options: [
|
||||
{
|
||||
flag: "<identifier>",
|
||||
description:
|
||||
"Community identifier: npub, nprofile, ncommunity://... format, hex pubkey, or NIP-05 (user@domain.com)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"community npub1... View community by npub",
|
||||
"community nprofile1... View community with relay hints",
|
||||
"community ncommunity://<pubkey>?relay=... View using ncommunity format",
|
||||
"community community@domain.com View community by NIP-05",
|
||||
"community $me View your own community (if exists)",
|
||||
],
|
||||
seeAlso: ["profile", "chat", "open"],
|
||||
appId: "community",
|
||||
category: "Nostr",
|
||||
argParser: async (args: string[], activeAccountPubkey?: string) => {
|
||||
return await parseCommunityCommand(args, activeAccountPubkey);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user