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:
Claude
2026-01-20 10:04:04 +00:00
parent c2f6f1bcd2
commit dd5f7021cf
10 changed files with 1878 additions and 0 deletions

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

View File

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

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

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

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

View File

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

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

View File

@@ -23,6 +23,7 @@ export type AppId =
| "blossom"
| "wallet"
| "zap"
| "community"
| "win";
export interface WindowInstance {

View File

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