feat: add Communikeys support (kind 10222/30222)

Implement Communikeys NIP for community creation and targeted publishing:

Phase 1 - Constants & Helpers:
- Add kind 10222 (Community Definition) and 30222 (Targeted Publication)
- Create communikeys-helpers.ts with parsing for content sections, relays, mints

Phase 2 - Renderers:
- Add CommunikeyRenderer/CommunikeyDetailRenderer for kind 10222
- Add TargetedPublicationRenderer/TargetedPublicationDetailRenderer for kind 30222

Phase 3 - Community Viewer:
- Add communikey command to view community details
- Create CommunikeyViewer with profile, relays, content sections, chat integration

Phase 4 - Chat Adapter:
- Create CommunikeysAdapter for community chat (kind 9 with h-tag pubkey)
- Update chat-parser to support npub/nprofile/hex community identifiers
This commit is contained in:
Claude
2026-01-12 11:26:25 +00:00
parent e50fcca386
commit 22dffe2271
15 changed files with 2123 additions and 12 deletions

View File

@@ -0,0 +1,433 @@
import { useEffect } from "react";
import { useEventStore, use$ } from "applesauce-react/hooks";
import { addressLoader } from "@/services/loaders";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import { useGrimoire } from "@/core/state";
import {
getCommunikeyRelays,
getCommunikeyContentSections,
getCommunikeyDescription,
getCommunikeyBlossomServers,
getCommunikeyMints,
getCommunikeyLocation,
getCommunikeyTos,
} from "@/lib/communikeys-helpers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Users,
Radio,
MessageCircle,
Server,
Coins,
MapPin,
FileText,
Award,
Lock,
Copy,
CopyCheck,
User as UserIcon,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { UserName } from "./nostr/UserName";
import { useCopy } from "@/hooks/useCopy";
import { getKindName, getKindIcon } from "@/constants/kinds";
import type { ContentSection } from "@/lib/communikeys-helpers";
const COMMUNIKEY_KIND = 10222;
export interface CommunikeyViewerProps {
pubkey: string;
relays?: string[];
}
/**
* CommunikeyViewer - View a Communikey community
* Shows community profile, configuration, and content sections
*/
export function CommunikeyViewer({ pubkey, relays }: CommunikeyViewerProps) {
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;
// Get community profile (kind:0 metadata)
const profile = useProfile(resolvedPubkey);
const displayName = getDisplayName(resolvedPubkey || "", profile);
// Fetch community config (kind:10222) from network
useEffect(() => {
if (!resolvedPubkey) return;
const subscription = addressLoader({
kind: COMMUNIKEY_KIND,
pubkey: resolvedPubkey,
identifier: "",
relays: relays,
}).subscribe({
error: (err) => {
console.debug(
`[CommunikeyViewer] Failed to fetch community config for ${resolvedPubkey.slice(0, 8)}:`,
err,
);
},
});
return () => subscription.unsubscribe();
}, [resolvedPubkey, relays]);
// Get community config event (kind 10222) from store
const communityEvent = use$(
() =>
resolvedPubkey
? eventStore.replaceable(COMMUNIKEY_KIND, resolvedPubkey, "")
: undefined,
[eventStore, resolvedPubkey],
);
// Parse community configuration
const communityRelays = communityEvent
? getCommunikeyRelays(communityEvent)
: [];
const contentSections = communityEvent
? getCommunikeyContentSections(communityEvent)
: [];
const description =
(communityEvent ? getCommunikeyDescription(communityEvent) : null) ||
profile?.about;
const blossomServers = communityEvent
? getCommunikeyBlossomServers(communityEvent)
: [];
const mints = communityEvent ? getCommunikeyMints(communityEvent) : [];
const location = communityEvent
? getCommunikeyLocation(communityEvent)
: undefined;
const tos = communityEvent ? getCommunikeyTos(communityEvent) : undefined;
// Check if chat is supported (kind 9 in any section)
const hasChat = contentSections.some((section) => section.kinds.includes(9));
// Generate npub for copying
const npub = resolvedPubkey ? nip19.npubEncode(resolvedPubkey) : "";
// Open chat for this community
const openChat = () => {
if (resolvedPubkey) {
addWindow("chat", { identifier: npub });
}
};
// View community profile
const viewProfile = () => {
if (resolvedPubkey) {
addWindow("profile", { pubkey: resolvedPubkey });
}
};
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">
<UserIcon 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>
);
}
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">
{/* Left: npub */}
<button
onClick={() => copy(npub)}
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>
{/* Right: Community icon */}
<div className="flex items-center gap-3 flex-shrink-0">
<div className="flex items-center gap-1 text-muted-foreground">
<Users className="size-3" />
<span>Communikey</span>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-auto">
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
{profile?.picture ? (
<img
src={profile.picture}
alt={displayName}
className="size-16 rounded-full object-cover"
/>
) : (
<div className="size-16 rounded-full bg-muted flex items-center justify-center">
<Users className="size-8 text-muted-foreground" />
</div>
)}
<div>
<h1 className="text-2xl font-bold">{displayName}</h1>
<button
onClick={viewProfile}
className="text-sm text-muted-foreground hover:underline"
>
<UserName pubkey={resolvedPubkey} className="text-sm" />
</button>
</div>
</div>
{hasChat && (
<Button onClick={openChat} className="gap-2">
<MessageCircle className="size-4" />
Open Chat
</Button>
)}
</div>
{/* Description */}
{description && (
<p className="text-muted-foreground whitespace-pre-wrap">
{description}
</p>
)}
{/* Location */}
{location && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="size-4" />
{location}
</div>
)}
{/* Quick stats */}
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1">
<Radio className="size-3" />
{communityRelays.length}{" "}
{communityRelays.length === 1 ? "relay" : "relays"}
</Badge>
<Badge variant="secondary" className="gap-1">
<FileText className="size-3" />
{contentSections.length}{" "}
{contentSections.length === 1 ? "section" : "sections"}
</Badge>
{blossomServers.length > 0 && (
<Badge variant="secondary" className="gap-1">
<Server className="size-3" />
{blossomServers.length} blossom{" "}
{blossomServers.length === 1 ? "server" : "servers"}
</Badge>
)}
{mints.length > 0 && (
<Badge variant="secondary" className="gap-1">
<Coins className="size-3" />
{mints.length} {mints.length === 1 ? "mint" : "mints"}
</Badge>
)}
</div>
{/* No community config warning */}
{!communityEvent && (
<div className="p-3 rounded-md bg-muted/50 text-sm text-muted-foreground">
<p>
No community configuration found (kind 10222). This pubkey may
not have set up a Communikey community yet.
</p>
</div>
)}
</header>
{/* Content Sections */}
{contentSections.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4">Content Sections</h2>
<div className="grid gap-4">
{contentSections.map((section) => (
<ContentSectionCard key={section.name} section={section} />
))}
</div>
</section>
)}
{/* Relays */}
{communityRelays.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Radio className="size-5" />
Relays
</h2>
<div className="grid gap-2">
{communityRelays.map((relay, index) => (
<div
key={relay}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Radio className="size-4 text-muted-foreground" />
<code className="text-sm flex-1 break-all">{relay}</code>
{index === 0 && (
<Badge variant="outline" className="text-xs">
Main
</Badge>
)}
</div>
))}
</div>
</section>
)}
{/* Blossom Servers */}
{blossomServers.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Server className="size-5" />
Blossom Servers
</h2>
<div className="grid gap-2">
{blossomServers.map((server) => (
<div
key={server}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Server className="size-4 text-muted-foreground" />
<code className="text-sm flex-1 break-all">{server}</code>
</div>
))}
</div>
</section>
)}
{/* Ecash Mints */}
{mints.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Coins className="size-5" />
Ecash Mints
</h2>
<div className="grid gap-2">
{mints.map((mint) => (
<div
key={mint.url}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Coins className="size-4 text-muted-foreground" />
<code className="text-sm flex-1 break-all">{mint.url}</code>
{mint.protocol && (
<Badge variant="outline" className="text-xs">
{mint.protocol}
</Badge>
)}
</div>
))}
</div>
</section>
)}
{/* Terms of Service */}
{tos && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<FileText className="size-5" />
Terms of Service
</h2>
<div className="p-2 rounded-md bg-muted/50">
<code className="text-sm break-all">{tos.id}</code>
{tos.relay && (
<p className="text-xs text-muted-foreground mt-1">
Relay: {tos.relay}
</p>
)}
</div>
</section>
)}
</div>
</div>
</div>
);
}
/**
* Card component for displaying a content section
*/
function ContentSectionCard({ section }: { section: ContentSection }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center justify-between">
<span>{section.name}</span>
<div className="flex gap-1">
{section.exclusive && (
<Badge variant="secondary" className="gap-1 text-xs">
<Lock className="size-3" />
Exclusive
</Badge>
)}
{section.fee && (
<Badge variant="secondary" className="gap-1 text-xs">
<Coins className="size-3" />
{section.fee.amount} {section.fee.unit}
</Badge>
)}
{section.badgeRequirement && (
<Badge variant="secondary" className="gap-1 text-xs">
<Award className="size-3" />
Badge Required
</Badge>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{section.kinds.map((kind) => {
const KindIcon = getKindIcon(kind);
return (
<Badge key={kind} variant="outline" className="gap-1">
<KindIcon className="size-3" />
{getKindName(kind)}
<span className="text-muted-foreground">({kind})</span>
</Badge>
);
})}
</div>
{section.badgeRequirement && (
<p className="text-xs text-muted-foreground mt-2">
Requires badge: <code>{section.badgeRequirement}</code>
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -30,6 +30,9 @@ const ConnViewer = lazy(() => import("./ConnViewer"));
const ChatViewer = lazy(() =>
import("./ChatViewer").then((m) => ({ default: m.ChatViewer })),
);
const CommunikeyViewer = lazy(() =>
import("./CommunikeyViewer").then((m) => ({ default: m.CommunikeyViewer })),
);
const SpellsViewer = lazy(() =>
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
);
@@ -181,6 +184,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "communikey":
content = (
<CommunikeyViewer
pubkey={window.props.pubkey}
relays={window.props.relays}
/>
);
break;
case "spells":
content = <SpellsViewer />;
break;

View File

@@ -0,0 +1,307 @@
import {
Users,
Radio,
MessageCircle,
Server,
Coins,
MapPin,
FileText,
Award,
Lock,
} from "lucide-react";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import {
getCommunikeyRelays,
getCommunikeyContentSections,
getCommunikeyDescription,
getCommunikeyBlossomServers,
getCommunikeyMints,
getCommunikeyLocation,
getCommunikeyTos,
type ContentSection,
} from "@/lib/communikeys-helpers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getKindName, getKindIcon } from "@/constants/kinds";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
import { nip19 } from "nostr-tools";
import type { NostrEvent } from "@/types/nostr";
/**
* Detail renderer for Kind 10222 - Communikey (Community Definition)
* Displays full community configuration including relays, content sections, and settings
*/
export function CommunikeyDetailRenderer({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
// Get community profile (kind:0 metadata)
const profile = useProfile(event.pubkey);
const displayName = getDisplayName(event.pubkey, profile);
// Get community configuration from the event
const relays = getCommunikeyRelays(event);
const contentSections = getCommunikeyContentSections(event);
const description = getCommunikeyDescription(event) || profile?.about;
const blossomServers = getCommunikeyBlossomServers(event);
const mints = getCommunikeyMints(event);
const location = getCommunikeyLocation(event);
const tos = getCommunikeyTos(event);
// Check if chat is supported (kind 9 in any section)
const hasChat = contentSections.some((section) => section.kinds.includes(9));
// Open chat for this community
const openChat = () => {
const npub = nip19.npubEncode(event.pubkey);
addWindow("chat", { identifier: npub });
};
// View community profile
const viewProfile = () => {
addWindow("profile", { pubkey: event.pubkey });
};
return (
<div dir="auto" className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
{profile?.picture ? (
<img
src={profile.picture}
alt={displayName}
className="size-16 rounded-full object-cover"
/>
) : (
<div className="size-16 rounded-full bg-muted flex items-center justify-center">
<Users className="size-8 text-muted-foreground" />
</div>
)}
<div>
<h1 className="text-2xl font-bold">{displayName}</h1>
<button
onClick={viewProfile}
className="text-sm text-muted-foreground hover:underline"
>
<UserName pubkey={event.pubkey} className="text-sm" />
</button>
</div>
</div>
{hasChat && (
<Button onClick={openChat} className="gap-2">
<MessageCircle className="size-4" />
Open Chat
</Button>
)}
</div>
{/* Description */}
{description && (
<p className="text-muted-foreground whitespace-pre-wrap">
{description}
</p>
)}
{/* Location */}
{location && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="size-4" />
{location}
</div>
)}
{/* Quick stats */}
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1">
<Radio className="size-3" />
{relays.length} {relays.length === 1 ? "relay" : "relays"}
</Badge>
<Badge variant="secondary" className="gap-1">
<FileText className="size-3" />
{contentSections.length}{" "}
{contentSections.length === 1 ? "section" : "sections"}
</Badge>
{blossomServers.length > 0 && (
<Badge variant="secondary" className="gap-1">
<Server className="size-3" />
{blossomServers.length} blossom{" "}
{blossomServers.length === 1 ? "server" : "servers"}
</Badge>
)}
{mints.length > 0 && (
<Badge variant="secondary" className="gap-1">
<Coins className="size-3" />
{mints.length} {mints.length === 1 ? "mint" : "mints"}
</Badge>
)}
</div>
</header>
{/* Content Sections */}
<section>
<h2 className="text-lg font-semibold mb-4">Content Sections</h2>
<div className="grid gap-4">
{contentSections.map((section) => (
<ContentSectionCard key={section.name} section={section} />
))}
{contentSections.length === 0 && (
<p className="text-muted-foreground text-sm">
No content sections defined
</p>
)}
</div>
</section>
{/* Relays */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Radio className="size-5" />
Relays
</h2>
<div className="grid gap-2">
{relays.map((relay, index) => (
<div
key={relay}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Radio className="size-4 text-muted-foreground" />
<code className="text-sm flex-1 break-all">{relay}</code>
{index === 0 && (
<Badge variant="outline" className="text-xs">
Main
</Badge>
)}
</div>
))}
{relays.length === 0 && (
<p className="text-muted-foreground text-sm">No relays specified</p>
)}
</div>
</section>
{/* Blossom Servers */}
{blossomServers.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Server className="size-5" />
Blossom Servers
</h2>
<div className="grid gap-2">
{blossomServers.map((server) => (
<div
key={server}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Server className="size-4 text-muted-foreground" />
<code className="text-sm flex-1 break-all">{server}</code>
</div>
))}
</div>
</section>
)}
{/* Ecash Mints */}
{mints.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Coins className="size-5" />
Ecash Mints
</h2>
<div className="grid gap-2">
{mints.map((mint) => (
<div
key={mint.url}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Coins className="size-4 text-muted-foreground" />
<code className="text-sm flex-1 break-all">{mint.url}</code>
{mint.protocol && (
<Badge variant="outline" className="text-xs">
{mint.protocol}
</Badge>
)}
</div>
))}
</div>
</section>
)}
{/* Terms of Service */}
{tos && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<FileText className="size-5" />
Terms of Service
</h2>
<div className="p-2 rounded-md bg-muted/50">
<code className="text-sm break-all">{tos.id}</code>
{tos.relay && (
<p className="text-xs text-muted-foreground mt-1">
Relay: {tos.relay}
</p>
)}
</div>
</section>
)}
</div>
);
}
/**
* Card component for displaying a content section
*/
function ContentSectionCard({ section }: { section: ContentSection }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center justify-between">
<span>{section.name}</span>
<div className="flex gap-1">
{section.exclusive && (
<Badge variant="secondary" className="gap-1 text-xs">
<Lock className="size-3" />
Exclusive
</Badge>
)}
{section.fee && (
<Badge variant="secondary" className="gap-1 text-xs">
<Coins className="size-3" />
{section.fee.amount} {section.fee.unit}
</Badge>
)}
{section.badgeRequirement && (
<Badge variant="secondary" className="gap-1 text-xs">
<Award className="size-3" />
Badge Required
</Badge>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{section.kinds.map((kind) => {
const KindIcon = getKindIcon(kind);
return (
<Badge key={kind} variant="outline" className="gap-1">
<KindIcon className="size-3" />
{getKindName(kind)}
<span className="text-muted-foreground">({kind})</span>
</Badge>
);
})}
</div>
{section.badgeRequirement && (
<p className="text-xs text-muted-foreground mt-2">
Requires badge: <code>{section.badgeRequirement}</code>
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,87 @@
import { Users, Radio, MessageCircle } from "lucide-react";
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import {
getCommunikeyRelays,
getCommunikeyContentSections,
getCommunikeyDescription,
} from "@/lib/communikeys-helpers";
import { Badge } from "@/components/ui/badge";
import { getKindName } from "@/constants/kinds";
/**
* Renderer for Kind 10222 - Communikey (Community Definition)
* Displays community name from profile, relay count, and content section badges
*/
export function CommunikeyRenderer({ event }: BaseEventProps) {
// Get community profile (kind:0 metadata)
const profile = useProfile(event.pubkey);
const displayName = getDisplayName(event.pubkey, profile);
// Get community configuration from the event
const relays = getCommunikeyRelays(event);
const contentSections = getCommunikeyContentSections(event);
const description = getCommunikeyDescription(event) || profile?.about;
// Check if chat is supported (kind 9 in any section)
const hasChat = contentSections.some((section) => section.kinds.includes(9));
return (
<BaseEventContainer event={event}>
<div dir="auto" className="flex flex-col gap-3">
{/* Community name and basic info */}
<div className="flex items-center gap-2">
<Users className="size-5 text-muted-foreground shrink-0" />
<ClickableEventTitle
event={event}
className="text-lg font-bold text-foreground"
>
{displayName}
</ClickableEventTitle>
</div>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
)}
{/* Stats and badges */}
<div className="flex flex-wrap items-center gap-2">
{/* Relay count */}
<Badge variant="secondary" className="gap-1">
<Radio className="size-3" />
{relays.length} {relays.length === 1 ? "relay" : "relays"}
</Badge>
{/* Chat indicator */}
{hasChat && (
<Badge variant="outline" className="gap-1">
<MessageCircle className="size-3" />
Chat
</Badge>
)}
{/* Content sections as badges */}
{contentSections.map((section) => (
<Badge
key={section.name}
variant="outline"
className="text-xs"
title={section.kinds.map((k) => getKindName(k)).join(", ")}
>
{section.name}
{section.exclusive && " (exclusive)"}
</Badge>
))}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,214 @@
import { Target, Users, ExternalLink } from "lucide-react";
import { DetailKindRenderer } from "./index";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import {
getTargetedPublicationEventId,
getTargetedPublicationAddress,
getTargetedPublicationKind,
getTargetedCommunities,
} from "@/lib/communikeys-helpers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
import { parseAddressPointer } from "@/lib/nip89-helpers";
import { getKindName } from "@/constants/kinds";
import type { NostrEvent } from "@/types/nostr";
/**
* Detail renderer for Kind 30222 - Targeted Publication
* Displays full targeted publication with communities and original content
*/
export function TargetedPublicationDetailRenderer({
event,
}: {
event: NostrEvent;
}) {
const { addWindow } = useGrimoire();
// Get the original publication reference
const eventId = getTargetedPublicationEventId(event);
const eventAddress = getTargetedPublicationAddress(event);
const publicationKind = getTargetedPublicationKind(event);
// Build pointer for the original event
let pointer: Parameters<typeof useNostrEvent>[0] = undefined;
if (eventId) {
pointer = { id: eventId };
} else if (eventAddress) {
const parsed = parseAddressPointer(eventAddress);
if (parsed) {
pointer = {
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier,
};
}
}
// Fetch the original publication
const originalEvent = useNostrEvent(pointer, event);
// Get targeted communities with relay hints
const communities = getTargetedCommunities(event);
// Open a community
const openCommunity = (pubkey: string) => {
addWindow("communikey", { pubkey });
};
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
{/* Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
<div className="flex items-center gap-2">
<Target className="size-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Targeted Publication</h1>
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>By</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
<span></span>
<span>
{new Date(event.created_at * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
{publicationKind && (
<>
<span></span>
<Badge variant="outline">
Original: {getKindName(publicationKind)}
</Badge>
</>
)}
</div>
</header>
{/* Targeted Communities */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Users className="size-5" />
Targeted Communities ({communities.length})
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{communities.map((community) => (
<CommunityCard
key={community.pubkey}
pubkey={community.pubkey}
relay={community.relay}
onOpen={() => openCommunity(community.pubkey)}
/>
))}
</div>
</section>
{/* Original Publication */}
<section>
<h2 className="text-lg font-semibold mb-4">Original Publication</h2>
{originalEvent ? (
<Card>
<CardContent className="p-0">
<DetailKindRenderer event={originalEvent} />
</CardContent>
</Card>
) : pointer ? (
<Card>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
<p className="text-sm text-muted-foreground mt-4">
Loading original publication
{publicationKind && ` (kind ${publicationKind})`}...
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground">
Original publication reference not found
</p>
{eventId && (
<p className="text-xs text-muted-foreground mt-2">
Event ID: <code>{eventId}</code>
</p>
)}
{eventAddress && (
<p className="text-xs text-muted-foreground mt-2">
Address: <code>{eventAddress}</code>
</p>
)}
</CardContent>
</Card>
)}
</section>
</div>
);
}
/**
* Card component for displaying a targeted community
*/
function CommunityCard({
pubkey,
relay,
onOpen,
}: {
pubkey: string;
relay?: string;
onOpen: () => void;
}) {
const profile = useProfile(pubkey);
const displayName = getDisplayName(pubkey, profile);
return (
<Card className="hover:bg-muted/50 transition-colors">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
{profile?.picture ? (
<img
src={profile.picture}
alt={displayName}
className="size-8 rounded-full object-cover"
/>
) : (
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
<Users className="size-4 text-muted-foreground" />
</div>
)}
<span className="truncate">{displayName}</span>
</div>
<Button variant="ghost" size="icon" onClick={onOpen}>
<ExternalLink className="size-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<p className="text-xs text-muted-foreground truncate">
<UserName pubkey={pubkey} className="text-xs" />
</p>
{relay && (
<p className="text-xs text-muted-foreground mt-1 truncate">
Relay: {relay}
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,106 @@
import { Target, Users } from "lucide-react";
import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
import { KindRenderer } from "./index";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import {
getTargetedPublicationEventId,
getTargetedPublicationAddress,
getTargetedPublicationKind,
getTargetedCommunityPubkeys,
} from "@/lib/communikeys-helpers";
import { parseAddressPointer } from "@/lib/nip89-helpers";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Renderer for Kind 30222 - Targeted Publication
* Displays the original publication with badges for targeted communities
*/
export function TargetedPublicationRenderer({
event,
depth = 0,
}: BaseEventProps) {
// Get the original publication reference
const eventId = getTargetedPublicationEventId(event);
const eventAddress = getTargetedPublicationAddress(event);
const publicationKind = getTargetedPublicationKind(event);
// Build pointer for the original event
let pointer: Parameters<typeof useNostrEvent>[0] = undefined;
if (eventId) {
pointer = { id: eventId };
} else if (eventAddress) {
const parsed = parseAddressPointer(eventAddress);
if (parsed) {
pointer = {
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier,
};
}
}
// Fetch the original publication
const originalEvent = useNostrEvent(pointer, event);
// Get targeted communities
const communityPubkeys = getTargetedCommunityPubkeys(event);
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-3">
{/* Header with target icon and communities */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Target className="size-4" />
<span>Targeted to:</span>
<div className="flex flex-wrap gap-1">
{communityPubkeys.slice(0, 5).map((pubkey) => (
<CommunityBadge key={pubkey} pubkey={pubkey} />
))}
{communityPubkeys.length > 5 && (
<Badge variant="outline" className="text-xs">
+{communityPubkeys.length - 5} more
</Badge>
)}
</div>
</div>
{/* Original publication */}
{originalEvent ? (
<div className="border-l-2 border-muted pl-3 -ml-1">
<KindRenderer event={originalEvent} depth={depth + 1} />
</div>
) : pointer ? (
<div className="border-l-2 border-muted pl-3 -ml-1 py-2">
<Skeleton className="h-20 w-full" />
<p className="text-xs text-muted-foreground mt-2">
Loading original publication
{publicationKind && ` (kind ${publicationKind})`}...
</p>
</div>
) : (
<div className="border-l-2 border-muted pl-3 -ml-1 py-2 text-sm text-muted-foreground">
Original publication reference not found
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* Badge component showing a community with its name
*/
function CommunityBadge({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
const displayName = getDisplayName(pubkey, profile);
return (
<Badge variant="secondary" className="gap-1 text-xs">
<Users className="size-3" />
{displayName}
</Badge>
);
}

View File

@@ -31,6 +31,10 @@ import { Kind30023Renderer } from "./ArticleRenderer";
import { Kind30023DetailRenderer } from "./ArticleDetailRenderer";
import { CommunityNIPRenderer } from "./CommunityNIPRenderer";
import { CommunityNIPDetailRenderer } from "./CommunityNIPDetailRenderer";
import { CommunikeyRenderer } from "./CommunikeyRenderer";
import { CommunikeyDetailRenderer } from "./CommunikeyDetailRenderer";
import { TargetedPublicationRenderer } from "./TargetedPublicationRenderer";
import { TargetedPublicationDetailRenderer } from "./TargetedPublicationDetailRenderer";
import { RepositoryRenderer } from "./RepositoryRenderer";
import { RepositoryDetailRenderer } from "./RepositoryDetailRenderer";
import { RepositoryStateRenderer } from "./RepositoryStateRenderer";
@@ -111,6 +115,8 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30618: RepositoryStateRenderer, // Repository State (NIP-34)
30777: SpellbookRenderer, // Spellbook (Grimoire)
30817: CommunityNIPRenderer, // Community NIP
10222: CommunikeyRenderer, // Communikey (Community Definition)
30222: TargetedPublicationRenderer, // Targeted Publication (Communikeys)
31922: CalendarDateEventRenderer, // Date-Based Calendar Event (NIP-52)
31923: CalendarTimeEventRenderer, // Time-Based Calendar Event (NIP-52)
31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89)
@@ -177,6 +183,8 @@ const detailRenderers: Record<
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
30817: CommunityNIPDetailRenderer, // Community NIP Detail
10222: CommunikeyDetailRenderer, // Communikey Detail (Community Definition)
30222: TargetedPublicationDetailRenderer, // Targeted Publication Detail (Communikeys)
31922: CalendarDateEventDetailRenderer, // Date-Based Calendar Event Detail (NIP-52)
31923: CalendarTimeEventDetailRenderer, // Time-Based Calendar Event Detail (NIP-52)
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)

View File

@@ -53,6 +53,7 @@ import {
Smile,
Star,
Tag,
Target,
Trash2,
User,
UserCheck,
@@ -849,6 +850,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
nip: "66",
icon: Activity,
},
10222: {
kind: 10222,
name: "Communikey",
description: "Community Definition",
nip: "Communikeys",
icon: Users,
},
10317: {
kind: 10317,
name: "Grasp List",
@@ -1191,6 +1199,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
nip: "66",
icon: Compass,
},
30222: {
kind: 30222,
name: "Targeted Publication",
description: "Publication targeted at communities",
nip: "Communikeys",
icon: Target,
},
30267: {
kind: 30267,
name: "App Collection",

View File

@@ -2,6 +2,7 @@ import type { ChatCommandResult } from "@/types/chat";
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
import { CommunikeysAdapter } from "./chat/adapters/communikeys-adapter";
// Import other adapters as they're implemented
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
@@ -13,8 +14,9 @@ import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
* 1. NIP-17 (encrypted DMs) - prioritized for privacy
* 2. NIP-28 (channels) - specific event format (kind 40)
* 3. NIP-29 (groups) - specific group ID format
* 4. NIP-53 (live chat) - specific addressable format (kind 30311)
* 5. NIP-C7 (simple chat) - fallback for generic pubkeys
* 4. Communikeys (communities) - npub/nprofile/hex pubkey format
* 5. NIP-53 (live chat) - specific addressable format (kind 30311)
* 6. NIP-C7 (simple chat) - fallback for generic pubkeys
*
* @param args - Command arguments (first arg is the identifier)
* @returns Parsed result with protocol and identifier
@@ -38,9 +40,10 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
const adapters = [
// new Nip17Adapter(), // Phase 2
// new Nip28Adapter(), // Phase 3
new Nip29Adapter(), // Phase 4 - Relay groups
new Nip53Adapter(), // Phase 5 - Live activity chat
// new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now)
new Nip29Adapter(), // Relay groups (relay'group-id format)
new CommunikeysAdapter(), // Communikey communities (npub/nprofile/hex format)
new Nip53Adapter(), // Live activity chat (naddr kind 30311)
// new NipC7Adapter(), // Simple chat (disabled for now)
];
for (const adapter of adapters) {
@@ -65,6 +68,10 @@ Currently supported formats:
- naddr1... (NIP-29 group metadata, kind 39000)
Example:
chat naddr1qqxnzdesxqmnxvpexqmny...
- npub1.../nprofile1.../hex (Communikey community)
Examples:
chat npub1...
chat nprofile1...
- naddr1... (NIP-53 live activity chat, kind 30311)
Example:
chat naddr1... (live stream address)

View File

@@ -0,0 +1,437 @@
import { Observable } from "rxjs";
import { map, first } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
import type {
Conversation,
Message,
ProtocolIdentifier,
ChatCapabilities,
LoadMessagesOptions,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { publishEventToRelays } from "@/services/hub";
import accountManager from "@/services/accounts";
import { getTagValues } from "@/lib/nostr-utils";
import { EventFactory } from "applesauce-core/event-factory";
import { getProfileContent } from "applesauce-core/helpers";
import {
getCommunikeyRelays,
getCommunikeyDescription,
getCommunikeyContentSections,
} from "@/lib/communikeys-helpers";
import { isValidHexPubkey, normalizeHex } from "@/lib/nostr-validation";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
const COMMUNIKEY_KIND = 10222;
/**
* Communikeys Adapter - Community-Based Groups
*
* Features:
* - Any npub can become a community
* - Community config from kind:10222, profile from kind:0
* - Chat messages use kind:9 with h-tag containing community pubkey
* - Relays specified in community config (kind:10222 r-tags)
*
* Identifier formats:
* - npub1... (any npub can be a community)
* - nprofile1... (with relay hints)
* - hex pubkey (64 chars)
*/
export class CommunikeysAdapter extends ChatProtocolAdapter {
readonly protocol = "communikeys" as const;
readonly type = "group" as const;
/**
* Parse identifier - accepts npub, nprofile, or hex pubkey
* Returns null if identifier doesn't look like a pubkey
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Try npub format
if (input.startsWith("npub1")) {
try {
const decoded = nip19.decode(input);
if (decoded.type === "npub") {
return {
type: "communikey",
value: decoded.data,
relays: [],
};
}
} catch {
return null;
}
}
// Try nprofile format (with relay hints)
if (input.startsWith("nprofile1")) {
try {
const decoded = nip19.decode(input);
if (decoded.type === "nprofile") {
return {
type: "communikey",
value: decoded.data.pubkey,
relays: decoded.data.relays || [],
};
}
} catch {
return null;
}
}
// Try hex pubkey (64 chars)
if (isValidHexPubkey(input)) {
return {
type: "communikey",
value: normalizeHex(input),
relays: [],
};
}
return null;
}
/**
* Resolve conversation from community pubkey
* Fetches kind:0 profile and kind:10222 community config
*/
async resolveConversation(
identifier: ProtocolIdentifier,
): Promise<Conversation> {
const communityPubkey = identifier.value;
const hintRelays = identifier.relays || [];
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
throw new Error("No active account");
}
console.log(
`[Communikeys] Fetching community config for ${communityPubkey.slice(0, 8)}...`,
);
// Use hint relays + aggregators for fetching metadata
const fetchRelays = [...hintRelays, ...AGGREGATOR_RELAYS.slice(0, 3)];
// Fetch community config (kind:10222) and profile (kind:0)
const filter: Filter = {
kinds: [0, COMMUNIKEY_KIND],
authors: [communityPubkey],
limit: 2,
};
const events: NostrEvent[] = [];
const obs = pool.subscription(fetchRelays, [filter], { eventStore });
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
console.log("[Communikeys] Metadata fetch timeout");
resolve();
}, 5000);
const sub = obs.subscribe({
next: (response) => {
if (typeof response === "string") {
// EOSE received
clearTimeout(timeout);
console.log(`[Communikeys] Got ${events.length} metadata events`);
sub.unsubscribe();
resolve();
} else {
events.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error("[Communikeys] Metadata fetch error:", err);
sub.unsubscribe();
reject(err);
},
});
});
// Extract profile and community config
const profileEvent = events.find((e) => e.kind === 0);
const configEvent = events.find((e) => e.kind === COMMUNIKEY_KIND);
// Parse profile
const profile = profileEvent ? getProfileContent(profileEvent) : null;
const displayName =
profile?.display_name ||
profile?.name ||
`${communityPubkey.slice(0, 8)}...`;
// Parse community config
let communityRelays: string[] = [];
let description: string | undefined;
if (configEvent) {
communityRelays = getCommunikeyRelays(configEvent);
description = getCommunikeyDescription(configEvent) || profile?.about;
// Check if chat is supported (kind 9 in content sections)
const sections = getCommunikeyContentSections(configEvent);
const hasChat = sections.some((s) => s.kinds.includes(9));
if (!hasChat) {
console.warn(
"[Communikeys] Community does not have chat enabled (kind 9)",
);
}
}
console.log(
`[Communikeys] Community: ${displayName}, relays: ${communityRelays.length}`,
);
return {
id: `communikeys:${communityPubkey}`,
type: "group",
protocol: "communikeys",
title: displayName,
participants: [], // Could fetch from badge holders later
metadata: {
communityPubkey,
communityRelays,
description,
icon: profile?.picture,
},
unreadCount: 0,
};
}
/**
* Load messages for a community
* Uses kind:9 messages with h-tag = community pubkey
*/
loadMessages(
conversation: Conversation,
options?: LoadMessagesOptions,
): Observable<Message[]> {
const communityPubkey = conversation.metadata?.communityPubkey;
const communityRelays = conversation.metadata?.communityRelays || [];
if (!communityPubkey) {
throw new Error("Community pubkey required");
}
// Use community relays + aggregators for fetching
const fetchRelays =
communityRelays.length > 0
? communityRelays
: AGGREGATOR_RELAYS.slice(0, 3);
console.log(
`[Communikeys] Loading messages for ${communityPubkey.slice(0, 8)}... from ${fetchRelays.length} relays`,
);
// Subscribe to chat messages (kind 9) with h-tag = community pubkey
const filter: Filter = {
kinds: [9],
"#h": [communityPubkey],
limit: options?.limit || 50,
};
if (options?.before) {
filter.until = options.before;
}
if (options?.after) {
filter.since = options.after;
}
// Start persistent subscription
pool.subscription(fetchRelays, [filter], { eventStore }).subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[Communikeys] EOSE received for messages");
} else {
console.log(
`[Communikeys] Received message: ${response.id.slice(0, 8)}...`,
);
}
},
});
// Return observable from EventStore
return eventStore.timeline(filter).pipe(
map((events) => {
console.log(`[Communikeys] Timeline has ${events.length} messages`);
return events
.map((event) => this.eventToMessage(event, conversation.id))
.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
/**
* Load more historical messages (pagination)
*/
async loadMoreMessages(
_conversation: Conversation,
_before: number,
): Promise<Message[]> {
// Pagination to be implemented
return [];
}
/**
* Send a message to the community
*/
async sendMessage(
conversation: Conversation,
content: string,
options?: SendMessageOptions,
): Promise<void> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
throw new Error("No active account or signer");
}
const communityPubkey = conversation.metadata?.communityPubkey;
const communityRelays = conversation.metadata?.communityRelays || [];
if (!communityPubkey) {
throw new Error("Community pubkey required");
}
// Use community relays for publishing
const publishRelays =
communityRelays.length > 0
? communityRelays
: AGGREGATOR_RELAYS.slice(0, 3);
// Create event with h-tag = community pubkey
const factory = new EventFactory();
factory.setSigner(activeSigner);
const tags: string[][] = [["h", communityPubkey]];
if (options?.replyTo) {
// Use q-tag for replies (same as NIP-29 and NIP-C7)
tags.push(["q", options.replyTo]);
}
// Add NIP-30 emoji tags
if (options?.emojiTags) {
for (const emoji of options.emojiTags) {
tags.push(["emoji", emoji.shortcode, emoji.url]);
}
}
// Use kind 9 for chat messages
const draft = await factory.build({ kind: 9, content, tags });
const event = await factory.sign(draft);
// Publish to community relays
await publishEventToRelays(event, publishRelays);
}
/**
* Get protocol capabilities
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: false, // kind 9 messages are public
supportsThreading: true, // q-tag replies
supportsModeration: false, // badge-based, not relay-enforced
supportsRoles: true, // badge-based roles
supportsGroupManagement: false, // no join/leave required
canCreateConversations: false, // communities are created by publishing kind:10222
requiresRelay: true, // needs community relays
};
}
/**
* Load a replied-to message
*/
async loadReplyMessage(
conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
// Check EventStore first
const cachedEvent = await eventStore
.event(eventId)
.pipe(first())
.toPromise();
if (cachedEvent) {
return cachedEvent;
}
// Fetch from community relays
const communityRelays = conversation.metadata?.communityRelays || [];
const fetchRelays =
communityRelays.length > 0
? communityRelays
: AGGREGATOR_RELAYS.slice(0, 3);
console.log(
`[Communikeys] Fetching reply message ${eventId.slice(0, 8)}...`,
);
const filter: Filter = {
ids: [eventId],
limit: 1,
};
const events: NostrEvent[] = [];
const obs = pool.subscription(fetchRelays, [filter], { eventStore });
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.log(
`[Communikeys] Reply message fetch timeout for ${eventId.slice(0, 8)}...`,
);
resolve();
}, 3000);
const sub = obs.subscribe({
next: (response) => {
if (typeof response === "string") {
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
events.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error(`[Communikeys] Reply message fetch error:`, err);
sub.unsubscribe();
resolve();
},
});
});
return events[0] || null;
}
/**
* Helper: Convert Nostr event to Message
*/
private eventToMessage(event: NostrEvent, conversationId: string): Message {
// Look for reply q-tags
const qTags = getTagValues(event, "q");
const replyTo = qTags[0];
return {
id: event.id,
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo,
protocol: "communikeys",
metadata: {
encrypted: false,
},
event,
};
}
}

View File

@@ -0,0 +1,83 @@
import { nip19 } from "nostr-tools";
import { isNip05, resolveNip05 } from "./nip05";
import { isValidHexPubkey, normalizeHex } from "./nostr-validation";
export interface ParsedCommunikeyCommand {
pubkey: string;
relays?: string[];
}
/**
* Parse COMMUNIKEY command arguments into a community pubkey
* Supports:
* - npub1... (bech32 npub - any npub can be a community)
* - nprofile1... (bech32 nprofile with relay hints)
* - abc123... (64-char hex pubkey)
* - user@domain.com (NIP-05 identifier)
* - domain.com (bare domain, resolved as _@domain.com)
*
* Note: ncommunity format is planned but not yet implemented
*/
export async function parseCommunikeyCommand(
args: string[],
activeAccountPubkey?: string,
): Promise<ParsedCommunikeyCommand> {
const identifier = args[0];
if (!identifier) {
throw new Error("Community identifier required");
}
// Handle $me alias (view your own community profile)
if (identifier.toLowerCase() === "$me") {
return {
pubkey: activeAccountPubkey || "$me",
};
}
// Try bech32 decode first (npub, nprofile)
if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
try {
const decoded = nip19.decode(identifier);
if (decoded.type === "npub") {
// npub1... -> pubkey
return {
pubkey: decoded.data,
};
}
if (decoded.type === "nprofile") {
// nprofile1... -> pubkey with relay hints
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)
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..., hex pubkey, user@domain.com, or domain.com",
);
}

View File

@@ -0,0 +1,362 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
/**
* Communikeys Helper Functions
* Utility functions for parsing Communikey events (kind 10222 and 30222)
*
* Kind 10222: Community Definition Event
* Kind 30222: Targeted Publication Event
*/
// ============================================================================
// Types
// ============================================================================
export interface ContentSection {
name: string;
kinds: number[];
fee?: { amount: number; unit: string };
exclusive?: boolean;
badgeRequirement?: string; // "a" tag value like "30009:pubkey:badge-id"
}
export interface CommunikeyConfig {
relays: string[];
blossomServers: string[];
mints: string[];
contentSections: ContentSection[];
description?: string;
tos?: { id: string; relay?: string };
location?: string;
geohash?: string;
}
export interface TargetedCommunity {
pubkey: string;
relay?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags.filter((t) => t[0] === tagName).map((t) => t[1]);
}
// ============================================================================
// Community Definition Event Helpers (Kind 10222)
// ============================================================================
/**
* Get all relay URLs from a community definition event
* @param event Community event (kind 10222)
* @returns Array of relay URLs
*/
export function getCommunikeyRelays(event: NostrEvent): string[] {
return getTagValues(event, "r");
}
/**
* Get the main (first) relay URL from a community definition event
* @param event Community event (kind 10222)
* @returns Main relay URL or undefined
*/
export function getCommunikeyMainRelay(event: NostrEvent): string | undefined {
return getTagValue(event, "r");
}
/**
* Get all blossom server URLs from a community definition event
* @param event Community event (kind 10222)
* @returns Array of blossom server URLs
*/
export function getCommunikeyBlossomServers(event: NostrEvent): string[] {
return getTagValues(event, "blossom");
}
/**
* Get all ecash mint URLs from a community definition event
* @param event Community event (kind 10222)
* @returns Array of mint URLs with their protocols
*/
export function getCommunikeyMints(
event: NostrEvent,
): Array<{ url: string; protocol?: string }> {
return event.tags
.filter((t) => t[0] === "mint")
.map((t) => ({ url: t[1], protocol: t[2] }));
}
/**
* Get the description override from a community definition event
* @param event Community event (kind 10222)
* @returns Description string or undefined
*/
export function getCommunikeyDescription(
event: NostrEvent,
): string | undefined {
return getTagValue(event, "description");
}
/**
* Get the terms of service reference from a community definition event
* @param event Community event (kind 10222)
* @returns ToS object with event ID and optional relay, or undefined
*/
export function getCommunikeyTos(
event: NostrEvent,
): { id: string; relay?: string } | undefined {
const tosTag = event.tags.find((t) => t[0] === "tos");
if (!tosTag) return undefined;
return { id: tosTag[1], relay: tosTag[2] };
}
/**
* Get the location from a community definition event
* @param event Community event (kind 10222)
* @returns Location string or undefined
*/
export function getCommunikeyLocation(event: NostrEvent): string | undefined {
return getTagValue(event, "location");
}
/**
* Get the geohash from a community definition event
* @param event Community event (kind 10222)
* @returns Geohash string or undefined
*/
export function getCommunikeyGeohash(event: NostrEvent): string | undefined {
return getTagValue(event, "g");
}
/**
* Parse content sections from a community definition event
* Content sections are defined by sequential tags starting with ["content", "name"]
* followed by k, fee, exclusive, and a (badge) tags that apply to that section
*
* @param event Community event (kind 10222)
* @returns Array of parsed content sections
*/
export function getCommunikeyContentSections(
event: NostrEvent,
): ContentSection[] {
const sections: ContentSection[] = [];
let currentSection: ContentSection | null = null;
for (const tag of event.tags) {
const [tagName, ...values] = tag;
if (tagName === "content") {
// Start a new section
if (currentSection) {
sections.push(currentSection);
}
currentSection = {
name: values[0] || "Unnamed",
kinds: [],
};
} else if (currentSection) {
// Only process these tags if we're in a content section
switch (tagName) {
case "k":
// Add kind to current section
const kind = parseInt(values[0], 10);
if (!isNaN(kind)) {
currentSection.kinds.push(kind);
}
break;
case "fee":
// Fee format: ["fee", "amount", "unit"]
const amount = parseInt(values[0], 10);
if (!isNaN(amount)) {
currentSection.fee = { amount, unit: values[1] || "sat" };
}
break;
case "exclusive":
currentSection.exclusive = values[0] === "true";
break;
case "a":
// Badge requirement - only set if it looks like a badge address (30009:...)
if (values[0]?.startsWith("30009:")) {
currentSection.badgeRequirement = values[0];
}
break;
}
}
}
// Don't forget the last section
if (currentSection) {
sections.push(currentSection);
}
return sections;
}
/**
* Get the full community configuration from a kind 10222 event
* @param event Community event (kind 10222)
* @returns Parsed community configuration
*/
export function getCommunikeyConfig(event: NostrEvent): CommunikeyConfig {
return {
relays: getCommunikeyRelays(event),
blossomServers: getCommunikeyBlossomServers(event),
mints: getCommunikeyMints(event).map((m) => m.url),
contentSections: getCommunikeyContentSections(event),
description: getCommunikeyDescription(event),
tos: getCommunikeyTos(event),
location: getCommunikeyLocation(event),
geohash: getCommunikeyGeohash(event),
};
}
/**
* Check if a kind is supported in any content section of the community
* @param event Community event (kind 10222)
* @param kind Event kind to check
* @returns True if the kind is supported
*/
export function isCommunikeyKindSupported(
event: NostrEvent,
kind: number,
): boolean {
const sections = getCommunikeyContentSections(event);
return sections.some((s) => s.kinds.includes(kind));
}
/**
* Get the content section that supports a specific kind
* @param event Community event (kind 10222)
* @param kind Event kind to find
* @returns The content section supporting this kind, or undefined
*/
export function getCommunikeySectionForKind(
event: NostrEvent,
kind: number,
): ContentSection | undefined {
const sections = getCommunikeyContentSections(event);
return sections.find((s) => s.kinds.includes(kind));
}
// ============================================================================
// Targeted Publication Event Helpers (Kind 30222)
// ============================================================================
/**
* Get the original event ID from a targeted publication event
* @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 original event address from a targeted publication event
* Used for addressable events (kinds 30000-39999)
* @param event Targeted publication event (kind 30222)
* @returns Event address or undefined
*/
export function getTargetedPublicationAddress(
event: NostrEvent,
): string | undefined {
return getTagValue(event, "a");
}
/**
* Get the original publication's kind from a targeted publication event
* @param event Targeted publication event (kind 30222)
* @returns Event kind or undefined
*/
export function getTargetedPublicationKind(
event: NostrEvent,
): number | undefined {
const kindStr = getTagValue(event, "k");
if (!kindStr) return undefined;
const kind = parseInt(kindStr, 10);
return isNaN(kind) ? undefined : kind;
}
/**
* Get all targeted communities from a targeted publication event
* Communities are specified via p tags with optional r tags for relay hints
*
* @param event Targeted publication event (kind 30222)
* @returns Array of targeted community objects with pubkey and optional relay
*/
export function getTargetedCommunities(event: NostrEvent): TargetedCommunity[] {
const communities: TargetedCommunity[] = [];
const relayHints: string[] = [];
// Collect relay hints
for (const tag of event.tags) {
if (tag[0] === "r" && tag[1]) {
relayHints.push(tag[1]);
}
}
// Collect community pubkeys and pair with relays
let relayIndex = 0;
for (const tag of event.tags) {
if (tag[0] === "p" && tag[1]) {
communities.push({
pubkey: tag[1],
relay: relayHints[relayIndex],
});
relayIndex++;
}
}
return communities;
}
/**
* Get just the community pubkeys from a targeted publication event
* @param event Targeted publication event (kind 30222)
* @returns Array of community pubkeys
*/
export function getTargetedCommunityPubkeys(event: NostrEvent): string[] {
return getTagValues(event, "p");
}
/**
* Check if a publication targets a specific community
* @param event Targeted publication event (kind 30222)
* @param communityPubkey The community pubkey to check
* @returns True if the publication targets this community
*/
export function isTargetedToCommunity(
event: NostrEvent,
communityPubkey: string,
): boolean {
return getTargetedCommunityPubkeys(event).includes(communityPubkey);
}
// ============================================================================
// Community-Exclusive Content Helpers (h tag)
// ============================================================================
/**
* Get the community pubkey from an exclusive content event (e.g., kind 9 chat)
* @param event Content event with h tag
* @returns Community pubkey or undefined
*/
export function getExclusiveCommunityPubkey(
event: NostrEvent,
): string | undefined {
return getTagValue(event, "h");
}
/**
* Check if an event is exclusive community content
* @param event Any event
* @returns True if the event has an h tag (community-exclusive)
*/
export function isExclusiveCommunityContent(event: NostrEvent): boolean {
return event.tags.some((t) => t[0] === "h");
}

View File

@@ -17,6 +17,7 @@ export type AppId =
| "debug"
| "conn"
| "chat"
| "communikey"
| "spells"
| "spellbooks"
| "win";

View File

@@ -3,7 +3,13 @@ import type { NostrEvent } from "./nostr";
/**
* Chat protocol identifier
*/
export type ChatProtocol = "nip-c7" | "nip-17" | "nip-28" | "nip-29" | "nip-53";
export type ChatProtocol =
| "nip-c7"
| "nip-17"
| "nip-28"
| "nip-29"
| "nip-53"
| "communikeys";
/**
* Conversation type
@@ -64,6 +70,10 @@ export interface ConversationMetadata {
// NIP-17 DM
encrypted?: boolean;
giftWrapped?: boolean;
// Communikeys
communityPubkey?: string; // Community identifier (pubkey)
communityRelays?: string[]; // Community's relays from kind:10222
}
/**

View File

@@ -6,6 +6,7 @@ import { parseProfileCommand } from "@/lib/profile-parser";
import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseCommunikeyCommand } from "@/lib/communikey-parser";
export interface ManPageEntry {
name: string;
@@ -350,20 +351,22 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "chat <identifier>",
description:
"Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups and NIP-53 live activity chat. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event to join its chat.",
"Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, Communikey communities, and NIP-53 live activity chat. NIP-29 groups use 'relay'group-id' format. Communikey communities use npub, nprofile, or hex pubkey. NIP-53 live activities use naddr of a kind 30311 event.",
options: [
{
flag: "<identifier>",
description:
"NIP-29 group (relay'group-id) or NIP-53 live activity (naddr1...)",
"NIP-29: relay'group-id | Communikey: npub/nprofile/hex | NIP-53: naddr (kind 30311)",
},
],
examples: [
"chat relay.example.com'bitcoin-dev Join NIP-29 relay group",
"chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol",
"chat naddr1... Join NIP-53 live activity chat",
"chat relay.example.com'bitcoin-dev NIP-29 relay group",
"chat wss://nos.lol'welcome NIP-29 with explicit protocol",
"chat npub1... Communikey community",
"chat nprofile1... Communikey with relay hints",
"chat naddr1... NIP-53 live activity chat",
],
seeAlso: ["profile", "open", "req", "live"],
seeAlso: ["profile", "communikey", "open", "req", "live"],
appId: "chat",
category: "Nostr",
argParser: async (args: string[]) => {
@@ -402,6 +405,33 @@ export const manPages: Record<string, ManPageEntry> = {
return parsed;
},
},
communikey: {
name: "communikey",
section: "1",
synopsis: "communikey <identifier>",
description:
"View a Communikey community. Communikeys allow any existing npub to become a community with its own relays, content sections, and configuration. Accepts npub, nprofile, hex pubkeys, NIP-05 identifiers, and the $me alias.",
options: [
{
flag: "<identifier>",
description:
"Community identifier in any supported format (npub, nprofile, hex pubkey, NIP-05)",
},
],
examples: [
"communikey npub1... View community by npub",
"communikey nprofile1... View community with relay hints",
"communikey community@example.com View community by NIP-05",
"communikey $me View your own community",
],
seeAlso: ["profile", "chat", "req"],
appId: "communikey",
category: "Nostr",
argParser: async (args: string[], activeAccountPubkey?: string) => {
const parsed = await parseCommunikeyCommand(args, activeAccountPubkey);
return parsed;
},
},
encode: {
name: "encode",
section: "1",