diff --git a/src/components/CommunityViewer.tsx b/src/components/CommunityViewer.tsx
new file mode 100644
index 0000000..1e9c904
--- /dev/null
+++ b/src/components/CommunityViewer.tsx
@@ -0,0 +1,530 @@
+import { useEffect } from "react";
+import { useProfile } from "@/hooks/useProfile";
+import { getDisplayName } from "@/lib/nostr-utils";
+import { UserName } from "./nostr/UserName";
+import { useCopy } from "@/hooks/useCopy";
+import { useGrimoire } from "@/core/state";
+import { useEventStore, use$ } from "applesauce-react/hooks";
+import { addressLoader } from "@/services/loaders";
+import { nip19 } from "nostr-tools";
+import {
+ getCommunityRelays,
+ getCommunityDescription,
+ getCommunityContentSections,
+ getCommunityBlossomServers,
+ getCommunityMints,
+ getCommunityLocation,
+ getCommunityGeohash,
+ getCommunityTos,
+ getCommunityBadgeRequirements,
+ type ContentSection,
+} from "@/lib/communikeys-helpers";
+import {
+ Users2,
+ Server,
+ MapPin,
+ Layers,
+ Award,
+ FileText,
+ Flower2,
+ Coins,
+ Copy,
+ CopyCheck,
+ ExternalLink,
+ MessageSquare,
+ Search,
+ Loader2,
+} from "lucide-react";
+import type { Subscription } from "rxjs";
+
+export interface CommunityViewerProps {
+ pubkey: string;
+ relays?: string[];
+}
+
+// Kind number for Communikeys Community event
+const COMMUNIKEY_KIND = 10222;
+
+/**
+ * Content Section Card Component for displaying content sections
+ */
+function ContentSectionCard({
+ section,
+ communityPubkey,
+}: {
+ section: ContentSection;
+ communityPubkey: string;
+}) {
+ const { addWindow } = useGrimoire();
+
+ const handleQueryKind = (kind: number) => {
+ // Open a REQ window to query this kind for the community
+ addWindow("req", {
+ filter: {
+ kinds: [kind],
+ "#h": [communityPubkey],
+ limit: 50,
+ },
+ });
+ };
+
+ return (
+
Invalid community pubkey.
+ );
+ }
+
+ // Loading state
+ if (!profile && !communityEvent) {
+ return (
+
diff --git a/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx b/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
new file mode 100644
index 0000000..b4cead2
--- /dev/null
+++ b/src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
@@ -0,0 +1,344 @@
+import type { NostrEvent } from "@/types/nostr";
+import { useProfile } from "@/hooks/useProfile";
+import { getDisplayName } from "@/lib/nostr-utils";
+import {
+ getCommunityRelays,
+ getCommunityDescription,
+ getCommunityContentSections,
+ getCommunityBlossomServers,
+ getCommunityMints,
+ getCommunityLocation,
+ getCommunityGeohash,
+ getCommunityTos,
+ getCommunityBadgeRequirements,
+ type ContentSection,
+} from "@/lib/communikeys-helpers";
+import { UserName } from "../UserName";
+import { useGrimoire } from "@/core/state";
+import {
+ Users2,
+ Server,
+ MapPin,
+ Layers,
+ Award,
+ FileText,
+ Flower2,
+ Coins,
+ ExternalLink,
+} from "lucide-react";
+import { nip19 } from "nostr-tools";
+import { useCopy } from "@/hooks/useCopy";
+
+interface CommunikeyDetailRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Content Section Card Component
+ */
+function ContentSectionCard({ section }: { section: ContentSection }) {
+ return (
+
+
+
+ {section.name}
+
+
+ {/* Event Kinds */}
+
+
Allowed Kinds
+
+ {section.kinds.map((kind) => (
+
+ {kind}
+
+ ))}
+
+
+
+ {/* Badge Requirements */}
+ {section.badgePointers.length > 0 && (
+
+
+
+ Required Badges (any)
+
+
+ {section.badgePointers.map((pointer, idx) => (
+
+ {pointer}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+/**
+ * Detail renderer for Communikeys events (kind 10222)
+ * Shows full community information including all content sections,
+ * infrastructure (relays, blossom servers, mints), and metadata
+ */
+export function CommunikeyDetailRenderer({
+ event,
+}: CommunikeyDetailRendererProps) {
+ const { addWindow } = useGrimoire();
+ const { copy, copied } = useCopy();
+
+ // Community's identity comes from the pubkey's profile
+ const profile = useProfile(event.pubkey);
+ const displayName = getDisplayName(event.pubkey, profile);
+
+ // Extract all community metadata
+ const relays = getCommunityRelays(event);
+ const description = getCommunityDescription(event);
+ const contentSections = getCommunityContentSections(event);
+ const blossomServers = getCommunityBlossomServers(event);
+ const mints = getCommunityMints(event);
+ const location = getCommunityLocation(event);
+ const geohash = getCommunityGeohash(event);
+ const tos = getCommunityTos(event);
+ const badgeRequirements = getCommunityBadgeRequirements(event);
+
+ // Generate ncommunity identifier
+ const npub = nip19.npubEncode(event.pubkey);
+
+ const handleCopyNpub = () => {
+ copy(npub);
+ };
+
+ const handleOpenProfile = () => {
+ addWindow("profile", { pubkey: event.pubkey });
+ };
+
+ return (
+
+ {/* Header Section */}
+
+ {/* Community Avatar */}
+ {profile?.picture ? (
+
+ ) : (
+
+
+
+ )}
+
+ {/* Community Title & Description */}
+
+
+
+ {displayName}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ {/* Metadata Grid */}
+
+ {/* Admin/Owner */}
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+ {/* Community ID */}
+
+
Community ID
+
+
+ {npub.slice(0, 20)}...{npub.slice(-8)}
+
+
+ {copied ? "Copied!" : "Click to copy"}
+
+
+
+
+ {/* Location */}
+ {location && (
+
+
+
+ Location
+
+ {location}
+
+ )}
+
+ {/* Geohash */}
+ {geohash && (
+
+
Geohash
+ {geohash}
+
+ )}
+
+
+ {/* Content Sections */}
+ {contentSections.length > 0 && (
+
+
+
+ Content Sections
+
+
+ {contentSections.map((section, idx) => (
+
+ ))}
+
+
+ )}
+
+ {/* Infrastructure Section */}
+ {(relays.length > 0 || blossomServers.length > 0 || mints.length > 0) && (
+
+
Infrastructure
+
+ {/* Relays */}
+ {relays.length > 0 && (
+
+
+
+ Relays ({relays.length})
+
+
+ {relays.map((relay, idx) => (
+
+
+ {relay}
+
+ {idx === 0 && (
+ (main)
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Blossom Servers */}
+ {blossomServers.length > 0 && (
+
+
+
+ Blossom Servers ({blossomServers.length})
+
+
+ {blossomServers.map((server, idx) => (
+
+ {server}
+
+ ))}
+
+
+ )}
+
+ {/* Mints */}
+ {mints.length > 0 && (
+
+
+
+ Ecash Mints ({mints.length})
+
+
+ {mints.map((mint, idx) => (
+
+
+ {mint.url}
+
+ {mint.type && (
+
+ ({mint.type})
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Badge Requirements Summary */}
+ {badgeRequirements.length > 0 && (
+
+
+
+ Required Badges
+
+
+ Users need one of these badges to publish content in this community:
+
+
+ {badgeRequirements.map((badge, idx) => (
+
+ {badge}
+
+ ))}
+
+
+ )}
+
+ {/* Terms of Service */}
+ {tos && (
+
+
+
+ Terms of Service
+
+
+
+ {tos.reference}
+
+ {tos.relay && (
+
+ via {tos.relay}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/CommunikeyRenderer.tsx b/src/components/nostr/kinds/CommunikeyRenderer.tsx
new file mode 100644
index 0000000..8c5c87f
--- /dev/null
+++ b/src/components/nostr/kinds/CommunikeyRenderer.tsx
@@ -0,0 +1,114 @@
+import type { NostrEvent } from "@/types/nostr";
+import { useProfile } from "@/hooks/useProfile";
+import { getDisplayName } from "@/lib/nostr-utils";
+import {
+ getCommunityRelays,
+ getCommunityDescription,
+ getCommunityContentSections,
+ getCommunityLocation,
+} from "@/lib/communikeys-helpers";
+import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
+import { useGrimoire } from "@/core/state";
+import { Users2, Server, MapPin, Layers } from "lucide-react";
+
+interface CommunikeyRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Renderer for Communikeys events (kind 10222)
+ * Displays community info with name/image from profile metadata
+ * and community-specific data from the event tags
+ */
+export function CommunikeyRenderer({ event }: CommunikeyRendererProps) {
+ const { addWindow } = useGrimoire();
+
+ // Community's identity comes from the pubkey's profile
+ const profile = useProfile(event.pubkey);
+ const displayName = getDisplayName(event.pubkey, profile);
+
+ // Extract community metadata from event tags
+ const relays = getCommunityRelays(event);
+ const description = getCommunityDescription(event);
+ const contentSections = getCommunityContentSections(event);
+ const location = getCommunityLocation(event);
+
+ const handleOpenCommunity = () => {
+ addWindow("community", {
+ pubkey: event.pubkey,
+ relays: relays.length > 0 ? relays : undefined,
+ });
+ };
+
+ return (
+
+
+ {/* Community Avatar */}
+ {profile?.picture && (
+
+
+
+ )}
+
+
+ {/* Community Name */}
+
+
+ {displayName}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Metadata Row */}
+
+ {/* Relay Count */}
+ {relays.length > 0 && (
+
+
+ {relays.length} {relays.length === 1 ? "relay" : "relays"}
+
+ )}
+
+ {/* Content Sections */}
+ {contentSections.length > 0 && (
+
+
+ {contentSections.length}{" "}
+ {contentSections.length === 1 ? "section" : "sections"}
+
+ )}
+
+ {/* Location */}
+ {location && (
+
+
+ {location}
+
+ )}
+
+
+ {/* Open Community Button */}
+
+
+ View Community
+
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/TargetedPublicationRenderer.tsx b/src/components/nostr/kinds/TargetedPublicationRenderer.tsx
new file mode 100644
index 0000000..034539e
--- /dev/null
+++ b/src/components/nostr/kinds/TargetedPublicationRenderer.tsx
@@ -0,0 +1,328 @@
+import type { NostrEvent } from "@/types/nostr";
+import { useProfile } from "@/hooks/useProfile";
+import { getDisplayName } from "@/lib/nostr-utils";
+import {
+ getTargetedPublicationEventId,
+ getTargetedPublicationAddress,
+ getTargetedPublicationKind,
+ getTargetedCommunities,
+} from "@/lib/communikeys-helpers";
+import { BaseEventContainer } from "./BaseEventRenderer";
+import { UserName } from "../UserName";
+import { useGrimoire } from "@/core/state";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import { Share2, Users2, FileText, ExternalLink } from "lucide-react";
+import { KindRenderer } from "./index";
+
+interface TargetedPublicationRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Renderer for Targeted Publication events (kind 30222)
+ * Shows the publication being shared to communities
+ */
+export function TargetedPublicationRenderer({
+ event,
+}: TargetedPublicationRendererProps) {
+ const { addWindow } = useGrimoire();
+
+ // Get the original publication reference
+ const eventId = getTargetedPublicationEventId(event);
+ const address = getTargetedPublicationAddress(event);
+ const originalKind = getTargetedPublicationKind(event);
+ const targetedCommunities = getTargetedCommunities(event);
+
+ // Create pointer for the original event
+ const pointer = eventId
+ ? { id: eventId }
+ : address
+ ? (() => {
+ const [kind, pubkey, identifier] = address.split(":");
+ return {
+ kind: parseInt(kind, 10),
+ pubkey,
+ identifier,
+ };
+ })()
+ : undefined;
+
+ // Fetch the original publication
+ const originalEvent = useNostrEvent(pointer);
+
+ const handleOpenOriginal = () => {
+ if (pointer) {
+ addWindow("open", { pointer });
+ }
+ };
+
+ const handleOpenCommunity = (pubkey: string, relays?: string[]) => {
+ addWindow("community", { pubkey, relays });
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ Shared to {targetedCommunities.length} communities
+
+
+ {/* Target Communities */}
+
+ {targetedCommunities.map((community, idx) => (
+
+ handleOpenCommunity(
+ community.pubkey,
+ community.relay ? [community.relay] : undefined,
+ )
+ }
+ />
+ ))}
+
+
+ {/* Original Publication Preview */}
+ {originalEvent ? (
+
+
+
+ ) : pointer ? (
+
+
+
+
+ {originalKind ? `Kind ${originalKind}` : "Loading..."}
+
+
+
+
+ View
+
+
+ ) : null}
+
+
+ );
+}
+
+/**
+ * Small chip component for displaying a target community
+ */
+function CommunityChip({
+ pubkey,
+ relay,
+ onClick,
+}: {
+ pubkey: string;
+ relay?: string;
+ onClick: () => void;
+}) {
+ const profile = useProfile(pubkey);
+ const displayName = getDisplayName(pubkey, profile);
+
+ return (
+
+ {profile?.picture ? (
+
+ ) : (
+
+ )}
+ {displayName}
+
+ );
+}
+
+/**
+ * Detail renderer for Targeted Publication events (kind 30222)
+ */
+export function TargetedPublicationDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ const { addWindow } = useGrimoire();
+
+ // Get the original publication reference
+ const eventId = getTargetedPublicationEventId(event);
+ const address = getTargetedPublicationAddress(event);
+ const originalKind = getTargetedPublicationKind(event);
+ const targetedCommunities = getTargetedCommunities(event);
+
+ // Create pointer for the original event
+ const pointer = eventId
+ ? { id: eventId }
+ : address
+ ? (() => {
+ const [kind, pubkey, identifier] = address.split(":");
+ return {
+ kind: parseInt(kind, 10),
+ pubkey,
+ identifier,
+ };
+ })()
+ : undefined;
+
+ // Fetch the original publication
+ const originalEvent = useNostrEvent(pointer);
+
+ const handleOpenOriginal = () => {
+ if (pointer) {
+ addWindow("open", { pointer });
+ }
+ };
+
+ const handleOpenCommunity = (pubkey: string, relays?: string[]) => {
+ addWindow("community", { pubkey, relays });
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ Targeted Publication
+
+
+ Published by
+
+
+
+ {/* Target Communities */}
+
+
+
+ Target Communities ({targetedCommunities.length})
+
+
+ {targetedCommunities.map((community, idx) => (
+
+ handleOpenCommunity(
+ community.pubkey,
+ community.relay ? [community.relay] : undefined,
+ )
+ }
+ />
+ ))}
+
+
+
+ {/* Original Publication */}
+
+
+
+
+ Original Publication
+
+ {pointer && (
+
+
+ Open in new window
+
+ )}
+
+
+ {originalEvent ? (
+
+
+
+ ) : (
+
+ {originalKind
+ ? `Loading kind ${originalKind} event...`
+ : "Loading..."}
+
+ )}
+
+
+ {/* Metadata */}
+
+ {eventId && (
+
+
Referenced Event ID
+ {eventId}
+
+ )}
+ {address && (
+
+
Referenced Address
+ {address}
+
+ )}
+ {originalKind !== undefined && (
+
+
Original Kind
+ {originalKind}
+
+ )}
+
+
+ );
+}
+
+/**
+ * Card component for displaying a target community in detail view
+ */
+function CommunityTargetCard({
+ pubkey,
+ relay,
+ onClick,
+}: {
+ pubkey: string;
+ relay?: string;
+ onClick: () => void;
+}) {
+ const profile = useProfile(pubkey);
+ const displayName = getDisplayName(pubkey, profile);
+
+ return (
+
+ {profile?.picture ? (
+
+ ) : (
+
+
+
+ )}
+
+ {displayName}
+ {relay && (
+
+ via {relay}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index d14e79b..52c808c 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -148,6 +148,12 @@ import { BadgeAwardRenderer } from "./BadgeAwardRenderer";
import { BadgeAwardDetailRenderer } from "./BadgeAwardDetailRenderer";
import { ProfileBadgesRenderer } from "./ProfileBadgesRenderer";
import { ProfileBadgesDetailRenderer } from "./ProfileBadgesDetailRenderer";
+import { CommunikeyRenderer } from "./CommunikeyRenderer";
+import { CommunikeyDetailRenderer } from "./CommunikeyDetailRenderer";
+import {
+ TargetedPublicationRenderer,
+ TargetedPublicationDetailRenderer,
+} from "./TargetedPublicationRenderer";
/**
* Registry of kind-specific renderers
@@ -198,6 +204,7 @@ const kindRenderers: Record
> = {
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
+ 10222: CommunikeyRenderer, // Communikey Community (Communikeys)
10317: Kind10317Renderer, // User Grasp List (NIP-34)
13534: RelayMembersRenderer, // Relay Members (NIP-43)
30000: FollowSetRenderer, // Follow Sets (NIP-51)
@@ -211,6 +218,7 @@ const kindRenderers: Record> = {
30009: BadgeDefinitionRenderer, // Badge (NIP-58)
30015: InterestSetRenderer, // Interest Sets (NIP-51)
30023: Kind30023Renderer, // Long-form Article
+ 30222: TargetedPublicationRenderer, // Targeted Publication (Communikeys)
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
30063: ZapstoreReleaseRenderer, // Zapstore App Release
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
@@ -296,6 +304,7 @@ const detailRenderers: Record<
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51)
10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51)
+ 10222: CommunikeyDetailRenderer, // Communikey Community Detail (Communikeys)
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
@@ -308,6 +317,7 @@ const detailRenderers: Record<
30009: BadgeDefinitionDetailRenderer, // Badge Detail (NIP-58)
30015: InterestSetDetailRenderer, // Interest Sets Detail (NIP-51)
30023: Kind30023DetailRenderer, // Long-form Article Detail
+ 30222: TargetedPublicationDetailRenderer, // Targeted Publication Detail (Communikeys)
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
diff --git a/src/lib/communikeys-helpers.ts b/src/lib/communikeys-helpers.ts
new file mode 100644
index 0000000..4b9dce6
--- /dev/null
+++ b/src/lib/communikeys-helpers.ts
@@ -0,0 +1,340 @@
+import type { NostrEvent } from "@/types/nostr";
+import { getTagValue } from "applesauce-core/helpers";
+
+/**
+ * Communikeys Helper Functions
+ * Utility functions for parsing Communikeys events (kind 10222, kind 30222)
+ *
+ * Based on the Communikeys standard:
+ * - Kind 10222: Community Creation Event (replaceable)
+ * - Kind 30222: Targeted Publication Event (parameterized replaceable)
+ *
+ * Kind numbers:
+ * - 10222 is in the replaceable event range (10000-19999)
+ * - 30222 is in the parameterized replaceable event range (30000-39999)
+ */
+
+// ============================================================================
+// Community Event Helpers (Kind 10222)
+// ============================================================================
+
+/**
+ * Content section within a community definition
+ * Groups related event kinds with optional badge requirements
+ */
+export interface ContentSection {
+ name: string;
+ kinds: number[];
+ badgePointers: string[]; // a-tag references to badge definitions
+}
+
+/**
+ * Get all relay URLs from a community event
+ * First relay in the array is considered the main relay
+ * @param event Community event (kind 10222)
+ * @returns Array of relay URLs
+ */
+export function getCommunityRelays(event: NostrEvent): string[] {
+ return event.tags.filter((t) => t[0] === "r").map((t) => t[1]);
+}
+
+/**
+ * Get the main relay URL for a community
+ * @param event Community event (kind 10222)
+ * @returns Main relay URL or undefined
+ */
+export function getCommunityMainRelay(event: NostrEvent): string | undefined {
+ const relayTag = event.tags.find((t) => t[0] === "r");
+ return relayTag ? relayTag[1] : undefined;
+}
+
+/**
+ * Get all blossom server URLs from a community event
+ * @param event Community event (kind 10222)
+ * @returns Array of blossom server URLs
+ */
+export function getCommunityBlossomServers(event: NostrEvent): string[] {
+ return event.tags.filter((t) => t[0] === "blossom").map((t) => t[1]);
+}
+
+/**
+ * Get all ecash mint URLs from a community event
+ * Returns objects with URL and type (e.g., "cashu")
+ * @param event Community event (kind 10222)
+ * @returns Array of mint objects
+ */
+export function getCommunityMints(
+ event: NostrEvent,
+): Array<{ url: string; type?: string }> {
+ return event.tags
+ .filter((t) => t[0] === "mint")
+ .map((t) => ({
+ url: t[1],
+ type: t[2], // e.g., "cashu"
+ }));
+}
+
+/**
+ * Get the community description
+ * Falls back to event content if no description tag present
+ * @param event Community event (kind 10222)
+ * @returns Description text or undefined
+ */
+export function getCommunityDescription(event: NostrEvent): string | undefined {
+ return getTagValue(event, "description") || event.content || undefined;
+}
+
+/**
+ * Get the community location
+ * @param event Community event (kind 10222)
+ * @returns Location string or undefined
+ */
+export function getCommunityLocation(event: NostrEvent): string | undefined {
+ return getTagValue(event, "location");
+}
+
+/**
+ * Get the community geohash
+ * @param event Community event (kind 10222)
+ * @returns Geohash string or undefined
+ */
+export function getCommunityGeohash(event: NostrEvent): string | undefined {
+ return getTagValue(event, "g");
+}
+
+/**
+ * Get the terms of service reference for a community
+ * Returns the event ID/address and optional relay hint
+ * @param event Community event (kind 10222)
+ * @returns TOS reference object or undefined
+ */
+export function getCommunityTos(
+ event: NostrEvent,
+): { reference: string; relay?: string } | undefined {
+ const tosTag = event.tags.find((t) => t[0] === "tos");
+ if (!tosTag || !tosTag[1]) return undefined;
+ return {
+ reference: tosTag[1],
+ relay: tosTag[2],
+ };
+}
+
+/**
+ * Parse all content sections from a community event
+ * Content sections define what types of events the community supports
+ * and who can publish them (via badge requirements)
+ *
+ * Tags are sequential: ["content", "Chat"], ["k", "9"], ["a", "30009:..."]
+ * Each content tag starts a new section; k and a tags belong to the preceding content
+ *
+ * @param event Community event (kind 10222)
+ * @returns Array of content sections
+ */
+export function getCommunityContentSections(
+ event: NostrEvent,
+): ContentSection[] {
+ const sections: ContentSection[] = [];
+ let currentSection: ContentSection | null = null;
+
+ for (const tag of event.tags) {
+ if (tag[0] === "content" && tag[1]) {
+ // Start a new content section
+ if (currentSection) {
+ sections.push(currentSection);
+ }
+ currentSection = {
+ name: tag[1],
+ kinds: [],
+ badgePointers: [],
+ };
+ } else if (currentSection) {
+ if (tag[0] === "k" && tag[1]) {
+ // Add kind to current section
+ const kind = parseInt(tag[1], 10);
+ if (!isNaN(kind)) {
+ currentSection.kinds.push(kind);
+ }
+ } else if (tag[0] === "a" && tag[1]) {
+ // Add badge requirement to current section
+ currentSection.badgePointers.push(tag[1]);
+ }
+ }
+ }
+
+ // Don't forget the last section
+ if (currentSection) {
+ sections.push(currentSection);
+ }
+
+ return sections;
+}
+
+/**
+ * Get all unique event kinds supported by a community
+ * Aggregates kinds from all content sections
+ * @param event Community event (kind 10222)
+ * @returns Array of unique kind numbers
+ */
+export function getCommunitySupportedKinds(event: NostrEvent): number[] {
+ const sections = getCommunityContentSections(event);
+ const kinds = new Set();
+ for (const section of sections) {
+ for (const kind of section.kinds) {
+ kinds.add(kind);
+ }
+ }
+ return Array.from(kinds);
+}
+
+/**
+ * Get all unique badge pointers required by a community
+ * Aggregates badge requirements from all content sections
+ * @param event Community event (kind 10222)
+ * @returns Array of unique badge address pointers (a-tag format)
+ */
+export function getCommunityBadgeRequirements(event: NostrEvent): string[] {
+ const sections = getCommunityContentSections(event);
+ const badges = new Set();
+ for (const section of sections) {
+ for (const badge of section.badgePointers) {
+ badges.add(badge);
+ }
+ }
+ return Array.from(badges);
+}
+
+// ============================================================================
+// Targeted Publication Event Helpers (Kind 30222)
+// ============================================================================
+
+/**
+ * Get the d-tag identifier for a targeted publication
+ * @param event Targeted publication event (kind 30222)
+ * @returns Identifier string or undefined
+ */
+export function getTargetedPublicationIdentifier(
+ event: NostrEvent,
+): string | undefined {
+ return getTagValue(event, "d");
+}
+
+/**
+ * Get the referenced event ID from a targeted publication
+ * Uses the e-tag for non-addressable events
+ * @param event Targeted publication event (kind 30222)
+ * @returns Event ID or undefined
+ */
+export function getTargetedPublicationEventId(
+ event: NostrEvent,
+): string | undefined {
+ return getTagValue(event, "e");
+}
+
+/**
+ * Get the referenced address pointer from a targeted publication
+ * Uses the a-tag for addressable events
+ * @param event Targeted publication event (kind 30222)
+ * @returns Address pointer string (kind:pubkey:d-tag) or undefined
+ */
+export function getTargetedPublicationAddress(
+ event: NostrEvent,
+): string | undefined {
+ return getTagValue(event, "a");
+}
+
+/**
+ * Get the kind of the original publication being targeted
+ * @param event Targeted publication event (kind 30222)
+ * @returns Kind number or undefined
+ */
+export function getTargetedPublicationKind(
+ event: NostrEvent,
+): number | undefined {
+ const kTag = getTagValue(event, "k");
+ if (!kTag) return undefined;
+ const kind = parseInt(kTag, 10);
+ return isNaN(kind) ? undefined : kind;
+}
+
+/**
+ * Community target within a targeted publication
+ * Contains the community pubkey and optional main relay
+ */
+export interface CommunityTarget {
+ pubkey: string;
+ relay?: string;
+}
+
+/**
+ * Get all targeted communities from a targeted publication
+ * Parses alternating p and r tags to build community targets
+ * @param event Targeted publication event (kind 30222)
+ * @returns Array of community targets
+ */
+export function getTargetedCommunities(event: NostrEvent): CommunityTarget[] {
+ const communities: CommunityTarget[] = [];
+
+ // Parse p and r tags sequentially
+ // Each p tag is followed by its corresponding r tag
+ const pTags = event.tags.filter((t) => t[0] === "p");
+ const rTags = event.tags.filter((t) => t[0] === "r");
+
+ for (let i = 0; i < pTags.length; i++) {
+ const pubkey = pTags[i][1];
+ if (pubkey) {
+ communities.push({
+ pubkey,
+ relay: rTags[i]?.[1],
+ });
+ }
+ }
+
+ return communities;
+}
+
+// ============================================================================
+// Community-Exclusive Event Helpers (Kind 9, Kind 11)
+// ============================================================================
+
+/**
+ * Get the community pubkey from an exclusive event (kind 9, 11)
+ * These events use an h-tag to reference their community
+ * @param event Chat message (kind 9) or Forum post (kind 11)
+ * @returns Community pubkey or undefined
+ */
+export function getExclusiveEventCommunity(
+ event: NostrEvent,
+): string | undefined {
+ return getTagValue(event, "h");
+}
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+/**
+ * Check if an event is a Communikeys community event
+ * @param event Nostr event
+ * @returns True if kind 10222
+ */
+export function isCommunityEvent(event: NostrEvent): boolean {
+ return event.kind === 10222;
+}
+
+/**
+ * Check if an event is a Communikeys targeted publication event
+ * @param event Nostr event
+ * @returns True if kind 30222
+ */
+export function isTargetedPublicationEvent(event: NostrEvent): boolean {
+ return event.kind === 30222;
+}
+
+/**
+ * Check if a chat message or forum post belongs to a community
+ * @param event Chat message (kind 9) or Forum post (kind 11)
+ * @returns True if has h-tag (community reference)
+ */
+export function isExclusiveCommunityEvent(event: NostrEvent): boolean {
+ return (event.kind === 9 || event.kind === 11) && !!getTagValue(event, "h");
+}
diff --git a/src/lib/community-parser.ts b/src/lib/community-parser.ts
new file mode 100644
index 0000000..63ca620
--- /dev/null
+++ b/src/lib/community-parser.ts
@@ -0,0 +1,172 @@
+import { nip19 } from "nostr-tools";
+import { isNip05, resolveNip05 } from "./nip05";
+import { isValidHexPubkey, normalizeHex } from "./nostr-validation";
+
+export interface ParsedCommunityCommand {
+ /** The community's pubkey (also serves as unique identifier) */
+ pubkey: string;
+ /** Relay hints for fetching the community's kind 10222 event */
+ relays?: string[];
+}
+
+/**
+ * Parse the ncommunity:// format
+ * Format: ncommunity://?relay=&relay=
+ *
+ * @param identifier ncommunity:// string
+ * @returns Parsed pubkey and relays or null if not valid
+ */
+function parseNcommunityFormat(
+ identifier: string,
+): { pubkey: string; relays: string[] } | null {
+ if (!identifier.startsWith("ncommunity://")) {
+ return null;
+ }
+
+ try {
+ // Remove the ncommunity:// prefix
+ const rest = identifier.slice("ncommunity://".length);
+
+ // Split pubkey from query params
+ const [pubkey, queryString] = rest.split("?");
+
+ if (!pubkey || !isValidHexPubkey(pubkey)) {
+ return null;
+ }
+
+ // Parse relay query params
+ const relays: string[] = [];
+ if (queryString) {
+ const params = new URLSearchParams(queryString);
+ const relayParams = params.getAll("relay");
+ for (const relay of relayParams) {
+ try {
+ relays.push(decodeURIComponent(relay));
+ } catch {
+ // Skip invalid URL-encoded relay
+ }
+ }
+ }
+
+ return {
+ pubkey: normalizeHex(pubkey),
+ relays,
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Parse COMMUNITY command arguments into a community identifier
+ *
+ * Supports:
+ * - npub1... (bech32 npub - community pubkey)
+ * - nprofile1... (bech32 nprofile with relay hints)
+ * - ncommunity://?relay=... (Communikeys format)
+ * - abc123... (64-char hex pubkey)
+ * - user@domain.com (NIP-05 identifier - resolves to pubkey)
+ * - domain.com (bare domain, resolved as _@domain.com)
+ * - $me (active account alias)
+ *
+ * @param args Command arguments
+ * @param activeAccountPubkey Active account pubkey for $me alias
+ * @returns Parsed community command with pubkey and optional relay hints
+ */
+export async function parseCommunityCommand(
+ args: string[],
+ activeAccountPubkey?: string,
+): Promise {
+ const identifier = args[0];
+
+ if (!identifier) {
+ throw new Error("Community identifier required");
+ }
+
+ // Handle $me alias (view own community if it exists)
+ if (identifier.toLowerCase() === "$me") {
+ if (!activeAccountPubkey) {
+ throw new Error("No active account. Log in to use $me alias.");
+ }
+ return {
+ pubkey: activeAccountPubkey,
+ };
+ }
+
+ // Try ncommunity:// format first
+ const ncommunityResult = parseNcommunityFormat(identifier);
+ if (ncommunityResult) {
+ return {
+ pubkey: ncommunityResult.pubkey,
+ relays:
+ ncommunityResult.relays.length > 0
+ ? ncommunityResult.relays
+ : undefined,
+ };
+ }
+
+ // Try bech32 decode (npub, nprofile)
+ if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
+ try {
+ const decoded = nip19.decode(identifier);
+
+ if (decoded.type === "npub") {
+ return {
+ pubkey: decoded.data,
+ };
+ }
+
+ if (decoded.type === "nprofile") {
+ return {
+ pubkey: decoded.data.pubkey,
+ relays: decoded.data.relays,
+ };
+ }
+ } catch (error) {
+ throw new Error(`Invalid bech32 identifier: ${error}`);
+ }
+ }
+
+ // Check if it's a hex pubkey
+ if (isValidHexPubkey(identifier)) {
+ return {
+ pubkey: normalizeHex(identifier),
+ };
+ }
+
+ // Check if it's a NIP-05 identifier (user@domain.com or domain.com)
+ if (isNip05(identifier)) {
+ const pubkey = await resolveNip05(identifier);
+ if (!pubkey) {
+ throw new Error(
+ `Failed to resolve NIP-05 identifier: ${identifier}. Please check the identifier and try again.`,
+ );
+ }
+ return { pubkey };
+ }
+
+ throw new Error(
+ "Invalid community identifier. Supported formats: npub1..., nprofile1..., ncommunity://..., hex pubkey, user@domain.com, or domain.com",
+ );
+}
+
+/**
+ * Encode a community identifier to ncommunity format
+ *
+ * @param pubkey Community pubkey
+ * @param relays Optional relay hints
+ * @returns ncommunity:// formatted string
+ */
+export function encodeNcommunity(pubkey: string, relays?: string[]): string {
+ let result = `ncommunity://${pubkey}`;
+
+ if (relays && relays.length > 0) {
+ const params = new URLSearchParams();
+ for (const relay of relays) {
+ params.append("relay", relay);
+ }
+ result += `?${params.toString()}`;
+ }
+
+ return result;
+}
diff --git a/src/types/app.ts b/src/types/app.ts
index b7d1e88..dd48b48 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -23,6 +23,7 @@ export type AppId =
| "blossom"
| "wallet"
| "zap"
+ | "community"
| "win";
export interface WindowInstance {
diff --git a/src/types/man.ts b/src/types/man.ts
index 9031732..657707f 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -9,6 +9,7 @@ import { resolveNip05Batch, resolveDomainDirectoryBatch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseBlossomCommand } from "@/lib/blossom-parser";
import { parseZapCommand } from "@/lib/zap-parser";
+import { parseCommunityCommand } from "@/lib/community-parser";
export interface ManPageEntry {
name: string;
@@ -843,4 +844,31 @@ export const manPages: Record = {
category: "Nostr",
defaultProps: {},
},
+ community: {
+ name: "community",
+ section: "1",
+ synopsis: "community ",
+ description:
+ "View a Communikeys community (kind 10222). Communikeys are decentralized communities on Nostr that use existing keypairs and relays. Communities define content sections with badge-based access control, allowing any existing npub to become a community and any publication to be targeted at communities. Unlike NIP-29 groups, Communikeys work on any standard Nostr relay with client-side access control.",
+ options: [
+ {
+ flag: "",
+ description:
+ "Community identifier: npub, nprofile, ncommunity://... format, hex pubkey, or NIP-05 (user@domain.com)",
+ },
+ ],
+ examples: [
+ "community npub1... View community by npub",
+ "community nprofile1... View community with relay hints",
+ "community ncommunity://?relay=... View using ncommunity format",
+ "community community@domain.com View community by NIP-05",
+ "community $me View your own community (if exists)",
+ ],
+ seeAlso: ["profile", "chat", "open"],
+ appId: "community",
+ category: "Nostr",
+ argParser: async (args: string[], activeAccountPubkey?: string) => {
+ return await parseCommunityCommand(args, activeAccountPubkey);
+ },
+ },
};