From 22dffe2271f55277abf33f2093900b9bcf221440 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Jan 2026 11:26:25 +0000 Subject: [PATCH] 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 --- src/components/CommunikeyViewer.tsx | 433 +++++++++++++++++ src/components/WindowRenderer.tsx | 11 + .../nostr/kinds/CommunikeyDetailRenderer.tsx | 307 ++++++++++++ .../nostr/kinds/CommunikeyRenderer.tsx | 87 ++++ .../TargetedPublicationDetailRenderer.tsx | 214 +++++++++ .../kinds/TargetedPublicationRenderer.tsx | 106 +++++ src/components/nostr/kinds/index.tsx | 8 + src/constants/kinds.ts | 15 + src/lib/chat-parser.ts | 17 +- src/lib/chat/adapters/communikeys-adapter.ts | 437 ++++++++++++++++++ src/lib/communikey-parser.ts | 83 ++++ src/lib/communikeys-helpers.ts | 362 +++++++++++++++ src/types/app.ts | 1 + src/types/chat.ts | 12 +- src/types/man.ts | 42 +- 15 files changed, 2123 insertions(+), 12 deletions(-) create mode 100644 src/components/CommunikeyViewer.tsx create mode 100644 src/components/nostr/kinds/CommunikeyDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/CommunikeyRenderer.tsx create mode 100644 src/components/nostr/kinds/TargetedPublicationDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/TargetedPublicationRenderer.tsx create mode 100644 src/lib/chat/adapters/communikeys-adapter.ts create mode 100644 src/lib/communikey-parser.ts create mode 100644 src/lib/communikeys-helpers.ts 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 */} +
+
+ + Communikey +
+
+
+ + {/* Main Content */} +
+
+ {/* Header */} +
+
+
+ {profile?.picture ? ( + {displayName} + ) : ( +
+ +
+ )} +
+

{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} + ) : ( +
+ +
+ )} +
+

{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 Publication

+
+ + {/* Metadata */} +
+
+ By + +
+ + + {new Date(event.created_at * 1000).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + })} + + {publicationKind && ( + <> + + + Original: {getKindName(publicationKind)} + + + )} +
+
+ + {/* 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} + ) : ( +
+ +
+ )} + {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",