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 ( +
+

+ + {section.name} +

+ + {/* Event Kinds */} +
+ Content Types +
+ {section.kinds.map((kind) => ( + + ))} +
+
+ + {/* Badge Requirements */} + {section.badgePointers.length > 0 && ( +
+ + + Required Badges + +
+ {section.badgePointers.map((pointer, idx) => ( + + {pointer.length > 50 + ? `${pointer.slice(0, 25)}...${pointer.slice(-20)}` + : pointer} + + ))} +
+
+ )} +
+ ); +} + +/** + * CommunityViewer - Detailed view for a Communikeys community (kind 10222) + * Shows community information derived from profile + kind 10222 event + */ +export function CommunityViewer({ pubkey, relays }: CommunityViewerProps) { + const { state, addWindow } = useGrimoire(); + const accountPubkey = state.activeAccount?.pubkey; + const eventStore = useEventStore(); + const { copy, copied } = useCopy(); + + // Resolve $me alias + const resolvedPubkey = pubkey === "$me" ? accountPubkey : pubkey; + + // Fetch profile metadata (community name, picture, etc.) + const profile = useProfile(resolvedPubkey); + const displayName = getDisplayName(resolvedPubkey ?? "", profile); + + // Fetch kind 10222 community event + useEffect(() => { + let subscription: Subscription | null = null; + if (!resolvedPubkey) return; + + // Fetch the community event from network + subscription = addressLoader({ + kind: COMMUNIKEY_KIND, + pubkey: resolvedPubkey, + identifier: "", + relays, + }).subscribe({ + error: (err) => { + console.debug( + `[CommunityViewer] Failed to fetch community event for ${resolvedPubkey.slice(0, 8)}:`, + err, + ); + }, + }); + + return () => { + if (subscription) { + subscription.unsubscribe(); + } + }; + }, [resolvedPubkey, eventStore, relays]); + + // Get community event (kind 10222) from EventStore + const communityEvent = use$( + () => + resolvedPubkey + ? eventStore.replaceable(COMMUNIKEY_KIND, resolvedPubkey, "") + : undefined, + [eventStore, resolvedPubkey], + ); + + // Extract community metadata + const communityRelays = communityEvent + ? getCommunityRelays(communityEvent) + : []; + const description = communityEvent + ? getCommunityDescription(communityEvent) + : undefined; + const contentSections = communityEvent + ? getCommunityContentSections(communityEvent) + : []; + const blossomServers = communityEvent + ? getCommunityBlossomServers(communityEvent) + : []; + const mints = communityEvent ? getCommunityMints(communityEvent) : []; + const location = communityEvent + ? getCommunityLocation(communityEvent) + : undefined; + const geohash = communityEvent + ? getCommunityGeohash(communityEvent) + : undefined; + const tos = communityEvent ? getCommunityTos(communityEvent) : undefined; + const badgeRequirements = communityEvent + ? getCommunityBadgeRequirements(communityEvent) + : []; + + // Generate npub for display + const npub = resolvedPubkey ? nip19.npubEncode(resolvedPubkey) : ""; + + const handleCopyNpub = () => { + copy(npub); + }; + + const handleOpenProfile = () => { + if (resolvedPubkey) { + addWindow("profile", { pubkey: resolvedPubkey }); + } + }; + + const handleOpenChat = (kind: number) => { + if (!resolvedPubkey) return; + + // For kind 9 (chat) or kind 11 (forum), we can query community content + addWindow("req", { + filter: { + kinds: [kind], + "#h": [resolvedPubkey], + limit: 100, + }, + }); + }; + + const handleOpenRelayViewer = (url: string) => { + addWindow("relay", { url }); + }; + + // Handle $me alias without account + if (pubkey === "$me" && !accountPubkey) { + return ( +
+
+ +

Account Required

+

+ The $me alias + requires an active account. Please log in to view your community. +

+
+
+ ); + } + + if (!resolvedPubkey) { + return ( +
Invalid community pubkey.
+ ); + } + + // Loading state + if (!profile && !communityEvent) { + return ( +
+ +

Loading community...

+
+ ); + } + + // No community event found + const noCommunityEvent = profile && !communityEvent; + + return ( +
+ {/* Compact Header */} +
+ + +
+
+ + Community +
+ + {communityRelays.length > 0 && ( +
+ + {communityRelays.length} +
+ )} +
+
+ + {/* Community Content */} +
+
+ {/* Header Section */} +
+ {profile?.picture ? ( + {displayName} + ) : ( +
+ +
+ )} + +
+

{displayName}

+ {description && ( +

+ {description} +

+ )} + {location && ( +
+ + {location} +
+ )} +
+
+ + {/* Admin Info */} +
+ Admin: + + +
+ + {/* No Community Event Warning */} + {noCommunityEvent && ( +
+ +
+

+ No Community Found +

+

+ This pubkey does not have a Communikeys community event (kind{" "} + {COMMUNIKEY_KIND}). The profile exists but no community has + been created for it. +

+
+
+ )} + + {/* Content Sections */} + {contentSections.length > 0 && ( +
+

+ + Content Sections +

+
+ {contentSections.map((section, idx) => ( + + ))} +
+ + {/* Quick Actions for Chat/Forum */} + {contentSections.some((s) => s.kinds.includes(9)) && ( + + )} +
+ )} + + {/* Infrastructure */} + {(communityRelays.length > 0 || + blossomServers.length > 0 || + mints.length > 0) && ( +
+

Infrastructure

+ + {/* Relays */} + {communityRelays.length > 0 && ( +
+

+ + Relays +

+
+ {communityRelays.map((relay, idx) => ( +
+ + {idx === 0 && ( + (main) + )} +
+ ))} +
+
+ )} + + {/* Blossom Servers */} + {blossomServers.length > 0 && ( +
+

+ + Blossom Servers +

+
+ {blossomServers.map((server, idx) => ( + + {server} + + ))} +
+
+ )} + + {/* Mints */} + {mints.length > 0 && ( +
+

+ + Ecash Mints +

+
+ {mints.map((mint, idx) => ( +
+ + {mint.url} + + {mint.type && ( + + ({mint.type}) + + )} +
+ ))} +
+
+ )} +
+ )} + + {/* Badge Requirements */} + {badgeRequirements.length > 0 && ( +
+

+ + Required Badges +

+

+ Users need one of these badges to publish content: +

+
+ {badgeRequirements.map((badge, idx) => ( + + {badge.length > 40 + ? `${badge.slice(0, 20)}...${badge.slice(-15)}` + : badge} + + ))} +
+
+ )} + + {/* Terms of Service */} + {tos && ( +
+

+ + Terms of Service +

+
+ + {tos.reference} + + {tos.relay && ( + + via {tos.relay} + + )} +
+
+ )} + + {/* Geohash (technical detail) */} + {geohash && ( +
+ Geohash + {geohash} +
+ )} +
+
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 0ad2c71..445d165 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -47,6 +47,9 @@ const ZapWindow = lazy(() => import("./ZapWindow").then((m) => ({ default: m.ZapWindow })), ); const CountViewer = lazy(() => import("./CountViewer")); +const CommunityViewer = lazy(() => + import("./CommunityViewer").then((m) => ({ default: m.CommunityViewer })), +); // Loading fallback component function ViewerLoading() { @@ -241,6 +244,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "community": + content = ( + + ); + break; default: content = (
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 ? ( + {displayName} + ) : ( +
+ +
+ )} + + {/* 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 && ( +
+ {displayName} +
+ )} + +
+ {/* 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 */} + +
+
+
+ ); +} 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..."} + +
+ +
+ ) : 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 ( + + ); +} + +/** + * 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 && ( + + )} +
+ + {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 ( + + ); +} 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); + }, + }, };