diff --git a/src/components/CommunikeyViewer.tsx b/src/components/CommunikeyViewer.tsx
new file mode 100644
index 0000000..4b5e1c1
--- /dev/null
+++ b/src/components/CommunikeyViewer.tsx
@@ -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 (
+
+
+
+
Account Required
+
+ The $me alias
+ requires an active account. Please log in to view your community.
+
+
+
+ );
+ }
+
+ if (!resolvedPubkey) {
+ return (
+ Invalid community pubkey.
+ );
+ }
+
+ return (
+
+ {/* Compact Header */}
+
+ {/* Left: npub */}
+
+
+ {/* Right: Community icon */}
+
+
+
+ {/* Main Content */}
+
+
+ {/* Header */}
+
+
+
+ {profile?.picture ? (
+

+ ) : (
+
+
+
+ )}
+
+
{displayName}
+
+
+
+
+ {hasChat && (
+
+ )}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Location */}
+ {location && (
+
+
+ {location}
+
+ )}
+
+ {/* Quick stats */}
+
+
+
+ {communityRelays.length}{" "}
+ {communityRelays.length === 1 ? "relay" : "relays"}
+
+
+
+ {contentSections.length}{" "}
+ {contentSections.length === 1 ? "section" : "sections"}
+
+ {blossomServers.length > 0 && (
+
+
+ {blossomServers.length} blossom{" "}
+ {blossomServers.length === 1 ? "server" : "servers"}
+
+ )}
+ {mints.length > 0 && (
+
+
+ {mints.length} {mints.length === 1 ? "mint" : "mints"}
+
+ )}
+
+
+ {/* No community config warning */}
+ {!communityEvent && (
+
+
+ No community configuration found (kind 10222). This pubkey may
+ not have set up a Communikey community yet.
+
+
+ )}
+
+
+ {/* Content Sections */}
+ {contentSections.length > 0 && (
+
+ Content Sections
+
+ {contentSections.map((section) => (
+
+ ))}
+
+
+ )}
+
+ {/* Relays */}
+ {communityRelays.length > 0 && (
+
+
+
+ Relays
+
+
+ {communityRelays.map((relay, index) => (
+
+
+ {relay}
+ {index === 0 && (
+
+ Main
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Blossom Servers */}
+ {blossomServers.length > 0 && (
+
+
+
+ Blossom Servers
+
+
+ {blossomServers.map((server) => (
+
+
+ {server}
+
+ ))}
+
+
+ )}
+
+ {/* Ecash Mints */}
+ {mints.length > 0 && (
+
+
+
+ Ecash Mints
+
+
+ {mints.map((mint) => (
+
+
+ {mint.url}
+ {mint.protocol && (
+
+ {mint.protocol}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Terms of Service */}
+ {tos && (
+
+
+
+ Terms of Service
+
+
+
{tos.id}
+ {tos.relay && (
+
+ Relay: {tos.relay}
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * Card component for displaying a content section
+ */
+function ContentSectionCard({ section }: { section: ContentSection }) {
+ return (
+
+
+
+ {section.name}
+
+ {section.exclusive && (
+
+
+ Exclusive
+
+ )}
+ {section.fee && (
+
+
+ {section.fee.amount} {section.fee.unit}
+
+ )}
+ {section.badgeRequirement && (
+
+
+ Badge Required
+
+ )}
+
+
+
+
+
+ {section.kinds.map((kind) => {
+ const KindIcon = getKindIcon(kind);
+ return (
+
+
+ {getKindName(kind)}
+ ({kind})
+
+ );
+ })}
+
+ {section.badgeRequirement && (
+
+ Requires badge: {section.badgeRequirement}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index 576d35e..b846c53 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -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 = (
+
+ );
+ break;
case "spells":
content = ;
break;
diff --git a/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx b/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
new file mode 100644
index 0000000..c78fbf1
--- /dev/null
+++ b/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
+ {profile?.picture ? (
+

+ ) : (
+
+
+
+ )}
+
+
{displayName}
+
+
+
+
+ {hasChat && (
+
+ )}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Location */}
+ {location && (
+
+
+ {location}
+
+ )}
+
+ {/* Quick stats */}
+
+
+
+ {relays.length} {relays.length === 1 ? "relay" : "relays"}
+
+
+
+ {contentSections.length}{" "}
+ {contentSections.length === 1 ? "section" : "sections"}
+
+ {blossomServers.length > 0 && (
+
+
+ {blossomServers.length} blossom{" "}
+ {blossomServers.length === 1 ? "server" : "servers"}
+
+ )}
+ {mints.length > 0 && (
+
+
+ {mints.length} {mints.length === 1 ? "mint" : "mints"}
+
+ )}
+
+
+
+ {/* Content Sections */}
+
+ Content Sections
+
+ {contentSections.map((section) => (
+
+ ))}
+ {contentSections.length === 0 && (
+
+ No content sections defined
+
+ )}
+
+
+
+ {/* Relays */}
+
+
+
+ Relays
+
+
+ {relays.map((relay, index) => (
+
+
+ {relay}
+ {index === 0 && (
+
+ Main
+
+ )}
+
+ ))}
+ {relays.length === 0 && (
+
No relays specified
+ )}
+
+
+
+ {/* Blossom Servers */}
+ {blossomServers.length > 0 && (
+
+
+
+ Blossom Servers
+
+
+ {blossomServers.map((server) => (
+
+
+ {server}
+
+ ))}
+
+
+ )}
+
+ {/* Ecash Mints */}
+ {mints.length > 0 && (
+
+
+
+ Ecash Mints
+
+
+ {mints.map((mint) => (
+
+
+ {mint.url}
+ {mint.protocol && (
+
+ {mint.protocol}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Terms of Service */}
+ {tos && (
+
+
+
+ Terms of Service
+
+
+
{tos.id}
+ {tos.relay && (
+
+ Relay: {tos.relay}
+
+ )}
+
+
+ )}
+
+ );
+}
+
+/**
+ * Card component for displaying a content section
+ */
+function ContentSectionCard({ section }: { section: ContentSection }) {
+ return (
+
+
+
+ {section.name}
+
+ {section.exclusive && (
+
+
+ Exclusive
+
+ )}
+ {section.fee && (
+
+
+ {section.fee.amount} {section.fee.unit}
+
+ )}
+ {section.badgeRequirement && (
+
+
+ Badge Required
+
+ )}
+
+
+
+
+
+ {section.kinds.map((kind) => {
+ const KindIcon = getKindIcon(kind);
+ return (
+
+
+ {getKindName(kind)}
+ ({kind})
+
+ );
+ })}
+
+ {section.badgeRequirement && (
+
+ Requires badge: {section.badgeRequirement}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/CommunikeyRenderer.tsx b/src/components/nostr/kinds/CommunikeyRenderer.tsx
new file mode 100644
index 0000000..a732ab9
--- /dev/null
+++ b/src/components/nostr/kinds/CommunikeyRenderer.tsx
@@ -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 (
+
+
+ {/* Community name and basic info */}
+
+
+
+ {displayName}
+
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Stats and badges */}
+
+ {/* Relay count */}
+
+
+ {relays.length} {relays.length === 1 ? "relay" : "relays"}
+
+
+ {/* Chat indicator */}
+ {hasChat && (
+
+
+ Chat
+
+ )}
+
+ {/* Content sections as badges */}
+ {contentSections.map((section) => (
+ getKindName(k)).join(", ")}
+ >
+ {section.name}
+ {section.exclusive && " (exclusive)"}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/TargetedPublicationDetailRenderer.tsx b/src/components/nostr/kinds/TargetedPublicationDetailRenderer.tsx
new file mode 100644
index 0000000..a772a7c
--- /dev/null
+++ b/src/components/nostr/kinds/TargetedPublicationDetailRenderer.tsx
@@ -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[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 (
+
+ {/* Header */}
+
+
+ {/* Targeted Communities */}
+
+
+
+ Targeted Communities ({communities.length})
+
+
+ {communities.map((community) => (
+ openCommunity(community.pubkey)}
+ />
+ ))}
+
+
+
+ {/* Original Publication */}
+
+ Original Publication
+ {originalEvent ? (
+
+
+
+
+
+ ) : pointer ? (
+
+
+
+
+
+
+
+
+
+ Loading original publication
+ {publicationKind && ` (kind ${publicationKind})`}...
+
+
+
+ ) : (
+
+
+
+ Original publication reference not found
+
+ {eventId && (
+
+ Event ID: {eventId}
+
+ )}
+ {eventAddress && (
+
+ Address: {eventAddress}
+
+ )}
+
+
+ )}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+ {profile?.picture ? (
+

+ ) : (
+
+
+
+ )}
+
{displayName}
+
+
+
+
+
+
+
+
+ {relay && (
+
+ Relay: {relay}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/TargetedPublicationRenderer.tsx b/src/components/nostr/kinds/TargetedPublicationRenderer.tsx
new file mode 100644
index 0000000..6bd2036
--- /dev/null
+++ b/src/components/nostr/kinds/TargetedPublicationRenderer.tsx
@@ -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[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 (
+
+
+ {/* Header with target icon and communities */}
+
+
+
Targeted to:
+
+ {communityPubkeys.slice(0, 5).map((pubkey) => (
+
+ ))}
+ {communityPubkeys.length > 5 && (
+
+ +{communityPubkeys.length - 5} more
+
+ )}
+
+
+
+ {/* Original publication */}
+ {originalEvent ? (
+
+
+
+ ) : pointer ? (
+
+
+
+ Loading original publication
+ {publicationKind && ` (kind ${publicationKind})`}...
+
+
+ ) : (
+
+ Original publication reference not found
+
+ )}
+
+
+ );
+}
+
+/**
+ * Badge component showing a community with its name
+ */
+function CommunityBadge({ pubkey }: { pubkey: string }) {
+ const profile = useProfile(pubkey);
+ const displayName = getDisplayName(pubkey, profile);
+
+ return (
+
+
+ {displayName}
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 2af9d0f..992ffe0 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -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> = {
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)
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index 9bc78e6..e8eb00a 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -53,6 +53,7 @@ import {
Smile,
Star,
Tag,
+ Target,
Trash2,
User,
UserCheck,
@@ -849,6 +850,13 @@ export const EVENT_KINDS: Record = {
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 = {
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",
diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts
index 3e91358..36e11e4 100644
--- a/src/lib/chat-parser.ts
+++ b/src/lib/chat-parser.ts
@@ -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)
diff --git a/src/lib/chat/adapters/communikeys-adapter.ts b/src/lib/chat/adapters/communikeys-adapter.ts
new file mode 100644
index 0000000..f76e208
--- /dev/null
+++ b/src/lib/chat/adapters/communikeys-adapter.ts
@@ -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 {
+ 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((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 {
+ 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 {
+ // Pagination to be implemented
+ return [];
+ }
+
+ /**
+ * Send a message to the community
+ */
+ async sendMessage(
+ conversation: Conversation,
+ content: string,
+ options?: SendMessageOptions,
+ ): Promise {
+ 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 {
+ // 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((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,
+ };
+ }
+}
diff --git a/src/lib/communikey-parser.ts b/src/lib/communikey-parser.ts
new file mode 100644
index 0000000..b191d80
--- /dev/null
+++ b/src/lib/communikey-parser.ts
@@ -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 {
+ 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",
+ );
+}
diff --git a/src/lib/communikeys-helpers.ts b/src/lib/communikeys-helpers.ts
new file mode 100644
index 0000000..5098569
--- /dev/null
+++ b/src/lib/communikeys-helpers.ts
@@ -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");
+}
diff --git a/src/types/app.ts b/src/types/app.ts
index 4b88e97..6849a60 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -17,6 +17,7 @@ export type AppId =
| "debug"
| "conn"
| "chat"
+ | "communikey"
| "spells"
| "spellbooks"
| "win";
diff --git a/src/types/chat.ts b/src/types/chat.ts
index bb9198d..25e394e 100644
--- a/src/types/chat.ts
+++ b/src/types/chat.ts
@@ -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
}
/**
diff --git a/src/types/man.ts b/src/types/man.ts
index a31f4c8..9c8424b 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -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 = {
section: "1",
synopsis: "chat ",
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: "",
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 = {
return parsed;
},
},
+ communikey: {
+ name: "communikey",
+ section: "1",
+ synopsis: "communikey ",
+ 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: "",
+ 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",