mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 18:21:28 +02:00
feat: add Communikeys support (kind 10222/30222)
Implement Communikeys NIP for community creation and targeted publishing: Phase 1 - Constants & Helpers: - Add kind 10222 (Community Definition) and 30222 (Targeted Publication) - Create communikeys-helpers.ts with parsing for content sections, relays, mints Phase 2 - Renderers: - Add CommunikeyRenderer/CommunikeyDetailRenderer for kind 10222 - Add TargetedPublicationRenderer/TargetedPublicationDetailRenderer for kind 30222 Phase 3 - Community Viewer: - Add communikey command to view community details - Create CommunikeyViewer with profile, relays, content sections, chat integration Phase 4 - Chat Adapter: - Create CommunikeysAdapter for community chat (kind 9 with h-tag pubkey) - Update chat-parser to support npub/nprofile/hex community identifiers
This commit is contained in:
433
src/components/CommunikeyViewer.tsx
Normal file
433
src/components/CommunikeyViewer.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEventStore, use$ } from "applesauce-react/hooks";
|
||||
import { addressLoader } from "@/services/loaders";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
getCommunikeyRelays,
|
||||
getCommunikeyContentSections,
|
||||
getCommunikeyDescription,
|
||||
getCommunikeyBlossomServers,
|
||||
getCommunikeyMints,
|
||||
getCommunikeyLocation,
|
||||
getCommunikeyTos,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Users,
|
||||
Radio,
|
||||
MessageCircle,
|
||||
Server,
|
||||
Coins,
|
||||
MapPin,
|
||||
FileText,
|
||||
Award,
|
||||
Lock,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { getKindName, getKindIcon } from "@/constants/kinds";
|
||||
import type { ContentSection } from "@/lib/communikeys-helpers";
|
||||
|
||||
const COMMUNIKEY_KIND = 10222;
|
||||
|
||||
export interface CommunikeyViewerProps {
|
||||
pubkey: string;
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CommunikeyViewer - View a Communikey community
|
||||
* Shows community profile, configuration, and content sections
|
||||
*/
|
||||
export function CommunikeyViewer({ pubkey, relays }: CommunikeyViewerProps) {
|
||||
const { state, addWindow } = useGrimoire();
|
||||
const accountPubkey = state.activeAccount?.pubkey;
|
||||
const eventStore = useEventStore();
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
// Resolve $me alias
|
||||
const resolvedPubkey = pubkey === "$me" ? accountPubkey : pubkey;
|
||||
|
||||
// Get community profile (kind:0 metadata)
|
||||
const profile = useProfile(resolvedPubkey);
|
||||
const displayName = getDisplayName(resolvedPubkey || "", profile);
|
||||
|
||||
// Fetch community config (kind:10222) from network
|
||||
useEffect(() => {
|
||||
if (!resolvedPubkey) return;
|
||||
|
||||
const subscription = addressLoader({
|
||||
kind: COMMUNIKEY_KIND,
|
||||
pubkey: resolvedPubkey,
|
||||
identifier: "",
|
||||
relays: relays,
|
||||
}).subscribe({
|
||||
error: (err) => {
|
||||
console.debug(
|
||||
`[CommunikeyViewer] Failed to fetch community config for ${resolvedPubkey.slice(0, 8)}:`,
|
||||
err,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [resolvedPubkey, relays]);
|
||||
|
||||
// Get community config event (kind 10222) from store
|
||||
const communityEvent = use$(
|
||||
() =>
|
||||
resolvedPubkey
|
||||
? eventStore.replaceable(COMMUNIKEY_KIND, resolvedPubkey, "")
|
||||
: undefined,
|
||||
[eventStore, resolvedPubkey],
|
||||
);
|
||||
|
||||
// Parse community configuration
|
||||
const communityRelays = communityEvent
|
||||
? getCommunikeyRelays(communityEvent)
|
||||
: [];
|
||||
const contentSections = communityEvent
|
||||
? getCommunikeyContentSections(communityEvent)
|
||||
: [];
|
||||
const description =
|
||||
(communityEvent ? getCommunikeyDescription(communityEvent) : null) ||
|
||||
profile?.about;
|
||||
const blossomServers = communityEvent
|
||||
? getCommunikeyBlossomServers(communityEvent)
|
||||
: [];
|
||||
const mints = communityEvent ? getCommunikeyMints(communityEvent) : [];
|
||||
const location = communityEvent
|
||||
? getCommunikeyLocation(communityEvent)
|
||||
: undefined;
|
||||
const tos = communityEvent ? getCommunikeyTos(communityEvent) : undefined;
|
||||
|
||||
// Check if chat is supported (kind 9 in any section)
|
||||
const hasChat = contentSections.some((section) => section.kinds.includes(9));
|
||||
|
||||
// Generate npub for copying
|
||||
const npub = resolvedPubkey ? nip19.npubEncode(resolvedPubkey) : "";
|
||||
|
||||
// Open chat for this community
|
||||
const openChat = () => {
|
||||
if (resolvedPubkey) {
|
||||
addWindow("chat", { identifier: npub });
|
||||
}
|
||||
};
|
||||
|
||||
// View community profile
|
||||
const viewProfile = () => {
|
||||
if (resolvedPubkey) {
|
||||
addWindow("profile", { pubkey: resolvedPubkey });
|
||||
}
|
||||
};
|
||||
|
||||
if (pubkey === "$me" && !accountPubkey) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<UserIcon className="size-12 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-semibold mb-2">Account Required</h3>
|
||||
<p className="text-sm max-w-md">
|
||||
The <code className="bg-muted px-1.5 py-0.5">$me</code> alias
|
||||
requires an active account. Please log in to view your community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!resolvedPubkey) {
|
||||
return (
|
||||
<div className="p-4 text-muted-foreground">Invalid community pubkey.</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Compact Header */}
|
||||
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
|
||||
{/* Left: npub */}
|
||||
<button
|
||||
onClick={() => copy(npub)}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
|
||||
title={npub}
|
||||
aria-label="Copy community ID"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 flex-shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-3 flex-shrink-0" />
|
||||
)}
|
||||
<code className="truncate">
|
||||
{npub.slice(0, 16)}...{npub.slice(-8)}
|
||||
</code>
|
||||
</button>
|
||||
|
||||
{/* Right: Community icon */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Users className="size-3" />
|
||||
<span>Communikey</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{displayName}</h1>
|
||||
<button
|
||||
onClick={viewProfile}
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
<UserName pubkey={resolvedPubkey} className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChat && (
|
||||
<Button onClick={openChat} className="gap-2">
|
||||
<MessageCircle className="size-4" />
|
||||
Open Chat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MapPin className="size-4" />
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Radio className="size-3" />
|
||||
{communityRelays.length}{" "}
|
||||
{communityRelays.length === 1 ? "relay" : "relays"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<FileText className="size-3" />
|
||||
{contentSections.length}{" "}
|
||||
{contentSections.length === 1 ? "section" : "sections"}
|
||||
</Badge>
|
||||
{blossomServers.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Server className="size-3" />
|
||||
{blossomServers.length} blossom{" "}
|
||||
{blossomServers.length === 1 ? "server" : "servers"}
|
||||
</Badge>
|
||||
)}
|
||||
{mints.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Coins className="size-3" />
|
||||
{mints.length} {mints.length === 1 ? "mint" : "mints"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No community config warning */}
|
||||
{!communityEvent && (
|
||||
<div className="p-3 rounded-md bg-muted/50 text-sm text-muted-foreground">
|
||||
<p>
|
||||
No community configuration found (kind 10222). This pubkey may
|
||||
not have set up a Communikey community yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Content Sections */}
|
||||
{contentSections.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Content Sections</h2>
|
||||
<div className="grid gap-4">
|
||||
{contentSections.map((section) => (
|
||||
<ContentSectionCard key={section.name} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Relays */}
|
||||
{communityRelays.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Radio className="size-5" />
|
||||
Relays
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{communityRelays.map((relay, index) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Radio className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{relay}</code>
|
||||
{index === 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Main
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Blossom Servers */}
|
||||
{blossomServers.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Server className="size-5" />
|
||||
Blossom Servers
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{blossomServers.map((server) => (
|
||||
<div
|
||||
key={server}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{server}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Ecash Mints */}
|
||||
{mints.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Coins className="size-5" />
|
||||
Ecash Mints
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{mints.map((mint) => (
|
||||
<div
|
||||
key={mint.url}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Coins className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{mint.url}</code>
|
||||
{mint.protocol && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{mint.protocol}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Terms of Service */}
|
||||
{tos && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="size-5" />
|
||||
Terms of Service
|
||||
</h2>
|
||||
<div className="p-2 rounded-md bg-muted/50">
|
||||
<code className="text-sm break-all">{tos.id}</code>
|
||||
{tos.relay && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Relay: {tos.relay}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component for displaying a content section
|
||||
*/
|
||||
function ContentSectionCard({ section }: { section: ContentSection }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span>{section.name}</span>
|
||||
<div className="flex gap-1">
|
||||
{section.exclusive && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Lock className="size-3" />
|
||||
Exclusive
|
||||
</Badge>
|
||||
)}
|
||||
{section.fee && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Coins className="size-3" />
|
||||
{section.fee.amount} {section.fee.unit}
|
||||
</Badge>
|
||||
)}
|
||||
{section.badgeRequirement && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Award className="size-3" />
|
||||
Badge Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.kinds.map((kind) => {
|
||||
const KindIcon = getKindIcon(kind);
|
||||
return (
|
||||
<Badge key={kind} variant="outline" className="gap-1">
|
||||
<KindIcon className="size-3" />
|
||||
{getKindName(kind)}
|
||||
<span className="text-muted-foreground">({kind})</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{section.badgeRequirement && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Requires badge: <code>{section.badgeRequirement}</code>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,9 @@ const ConnViewer = lazy(() => import("./ConnViewer"));
|
||||
const ChatViewer = lazy(() =>
|
||||
import("./ChatViewer").then((m) => ({ default: m.ChatViewer })),
|
||||
);
|
||||
const CommunikeyViewer = lazy(() =>
|
||||
import("./CommunikeyViewer").then((m) => ({ default: m.CommunikeyViewer })),
|
||||
);
|
||||
const SpellsViewer = lazy(() =>
|
||||
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
|
||||
);
|
||||
@@ -181,6 +184,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "communikey":
|
||||
content = (
|
||||
<CommunikeyViewer
|
||||
pubkey={window.props.pubkey}
|
||||
relays={window.props.relays}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "spells":
|
||||
content = <SpellsViewer />;
|
||||
break;
|
||||
|
||||
307
src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
Normal file
307
src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
Users,
|
||||
Radio,
|
||||
MessageCircle,
|
||||
Server,
|
||||
Coins,
|
||||
MapPin,
|
||||
FileText,
|
||||
Award,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import {
|
||||
getCommunikeyRelays,
|
||||
getCommunikeyContentSections,
|
||||
getCommunikeyDescription,
|
||||
getCommunikeyBlossomServers,
|
||||
getCommunikeyMints,
|
||||
getCommunikeyLocation,
|
||||
getCommunikeyTos,
|
||||
type ContentSection,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getKindName, getKindIcon } from "@/constants/kinds";
|
||||
import { UserName } from "../UserName";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 10222 - Communikey (Community Definition)
|
||||
* Displays full community configuration including relays, content sections, and settings
|
||||
*/
|
||||
export function CommunikeyDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get community profile (kind:0 metadata)
|
||||
const profile = useProfile(event.pubkey);
|
||||
const displayName = getDisplayName(event.pubkey, profile);
|
||||
|
||||
// Get community configuration from the event
|
||||
const relays = getCommunikeyRelays(event);
|
||||
const contentSections = getCommunikeyContentSections(event);
|
||||
const description = getCommunikeyDescription(event) || profile?.about;
|
||||
const blossomServers = getCommunikeyBlossomServers(event);
|
||||
const mints = getCommunikeyMints(event);
|
||||
const location = getCommunikeyLocation(event);
|
||||
const tos = getCommunikeyTos(event);
|
||||
|
||||
// Check if chat is supported (kind 9 in any section)
|
||||
const hasChat = contentSections.some((section) => section.kinds.includes(9));
|
||||
|
||||
// Open chat for this community
|
||||
const openChat = () => {
|
||||
const npub = nip19.npubEncode(event.pubkey);
|
||||
addWindow("chat", { identifier: npub });
|
||||
};
|
||||
|
||||
// View community profile
|
||||
const viewProfile = () => {
|
||||
addWindow("profile", { pubkey: event.pubkey });
|
||||
};
|
||||
|
||||
return (
|
||||
<div dir="auto" className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{displayName}</h1>
|
||||
<button
|
||||
onClick={viewProfile}
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
<UserName pubkey={event.pubkey} className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChat && (
|
||||
<Button onClick={openChat} className="gap-2">
|
||||
<MessageCircle className="size-4" />
|
||||
Open Chat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MapPin className="size-4" />
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Radio className="size-3" />
|
||||
{relays.length} {relays.length === 1 ? "relay" : "relays"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<FileText className="size-3" />
|
||||
{contentSections.length}{" "}
|
||||
{contentSections.length === 1 ? "section" : "sections"}
|
||||
</Badge>
|
||||
{blossomServers.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Server className="size-3" />
|
||||
{blossomServers.length} blossom{" "}
|
||||
{blossomServers.length === 1 ? "server" : "servers"}
|
||||
</Badge>
|
||||
)}
|
||||
{mints.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Coins className="size-3" />
|
||||
{mints.length} {mints.length === 1 ? "mint" : "mints"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Sections */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Content Sections</h2>
|
||||
<div className="grid gap-4">
|
||||
{contentSections.map((section) => (
|
||||
<ContentSectionCard key={section.name} section={section} />
|
||||
))}
|
||||
{contentSections.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No content sections defined
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Relays */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Radio className="size-5" />
|
||||
Relays
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{relays.map((relay, index) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Radio className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{relay}</code>
|
||||
{index === 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Main
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{relays.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">No relays specified</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Blossom Servers */}
|
||||
{blossomServers.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Server className="size-5" />
|
||||
Blossom Servers
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{blossomServers.map((server) => (
|
||||
<div
|
||||
key={server}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{server}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Ecash Mints */}
|
||||
{mints.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Coins className="size-5" />
|
||||
Ecash Mints
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{mints.map((mint) => (
|
||||
<div
|
||||
key={mint.url}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Coins className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{mint.url}</code>
|
||||
{mint.protocol && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{mint.protocol}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Terms of Service */}
|
||||
{tos && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="size-5" />
|
||||
Terms of Service
|
||||
</h2>
|
||||
<div className="p-2 rounded-md bg-muted/50">
|
||||
<code className="text-sm break-all">{tos.id}</code>
|
||||
{tos.relay && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Relay: {tos.relay}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component for displaying a content section
|
||||
*/
|
||||
function ContentSectionCard({ section }: { section: ContentSection }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span>{section.name}</span>
|
||||
<div className="flex gap-1">
|
||||
{section.exclusive && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Lock className="size-3" />
|
||||
Exclusive
|
||||
</Badge>
|
||||
)}
|
||||
{section.fee && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Coins className="size-3" />
|
||||
{section.fee.amount} {section.fee.unit}
|
||||
</Badge>
|
||||
)}
|
||||
{section.badgeRequirement && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Award className="size-3" />
|
||||
Badge Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.kinds.map((kind) => {
|
||||
const KindIcon = getKindIcon(kind);
|
||||
return (
|
||||
<Badge key={kind} variant="outline" className="gap-1">
|
||||
<KindIcon className="size-3" />
|
||||
{getKindName(kind)}
|
||||
<span className="text-muted-foreground">({kind})</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{section.badgeRequirement && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Requires badge: <code>{section.badgeRequirement}</code>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal file
87
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Users, Radio, MessageCircle } from "lucide-react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import {
|
||||
getCommunikeyRelays,
|
||||
getCommunikeyContentSections,
|
||||
getCommunikeyDescription,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getKindName } from "@/constants/kinds";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 10222 - Communikey (Community Definition)
|
||||
* Displays community name from profile, relay count, and content section badges
|
||||
*/
|
||||
export function CommunikeyRenderer({ event }: BaseEventProps) {
|
||||
// Get community profile (kind:0 metadata)
|
||||
const profile = useProfile(event.pubkey);
|
||||
const displayName = getDisplayName(event.pubkey, profile);
|
||||
|
||||
// Get community configuration from the event
|
||||
const relays = getCommunikeyRelays(event);
|
||||
const contentSections = getCommunikeyContentSections(event);
|
||||
const description = getCommunikeyDescription(event) || profile?.about;
|
||||
|
||||
// Check if chat is supported (kind 9 in any section)
|
||||
const hasChat = contentSections.some((section) => section.kinds.includes(9));
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div dir="auto" className="flex flex-col gap-3">
|
||||
{/* Community name and basic info */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-5 text-muted-foreground shrink-0" />
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-bold text-foreground"
|
||||
>
|
||||
{displayName}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats and badges */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Relay count */}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Radio className="size-3" />
|
||||
{relays.length} {relays.length === 1 ? "relay" : "relays"}
|
||||
</Badge>
|
||||
|
||||
{/* Chat indicator */}
|
||||
{hasChat && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<MessageCircle className="size-3" />
|
||||
Chat
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Content sections as badges */}
|
||||
{contentSections.map((section) => (
|
||||
<Badge
|
||||
key={section.name}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
title={section.kinds.map((k) => getKindName(k)).join(", ")}
|
||||
>
|
||||
{section.name}
|
||||
{section.exclusive && " (exclusive)"}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
214
src/components/nostr/kinds/TargetedPublicationDetailRenderer.tsx
Normal file
214
src/components/nostr/kinds/TargetedPublicationDetailRenderer.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Target, Users, ExternalLink } from "lucide-react";
|
||||
import { DetailKindRenderer } from "./index";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import {
|
||||
getTargetedPublicationEventId,
|
||||
getTargetedPublicationAddress,
|
||||
getTargetedPublicationKind,
|
||||
getTargetedCommunities,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { UserName } from "../UserName";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { parseAddressPointer } from "@/lib/nip89-helpers";
|
||||
import { getKindName } from "@/constants/kinds";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 30222 - Targeted Publication
|
||||
* Displays full targeted publication with communities and original content
|
||||
*/
|
||||
export function TargetedPublicationDetailRenderer({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get the original publication reference
|
||||
const eventId = getTargetedPublicationEventId(event);
|
||||
const eventAddress = getTargetedPublicationAddress(event);
|
||||
const publicationKind = getTargetedPublicationKind(event);
|
||||
|
||||
// Build pointer for the original event
|
||||
let pointer: Parameters<typeof useNostrEvent>[0] = undefined;
|
||||
if (eventId) {
|
||||
pointer = { id: eventId };
|
||||
} else if (eventAddress) {
|
||||
const parsed = parseAddressPointer(eventAddress);
|
||||
if (parsed) {
|
||||
pointer = {
|
||||
kind: parsed.kind,
|
||||
pubkey: parsed.pubkey,
|
||||
identifier: parsed.identifier,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the original publication
|
||||
const originalEvent = useNostrEvent(pointer, event);
|
||||
|
||||
// Get targeted communities with relay hints
|
||||
const communities = getTargetedCommunities(event);
|
||||
|
||||
// Open a community
|
||||
const openCommunity = (pubkey: string) => {
|
||||
addWindow("communikey", { pubkey });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="size-6 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">Targeted Publication</h1>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>By</span>
|
||||
<UserName pubkey={event.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{new Date(event.created_at * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
{publicationKind && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Badge variant="outline">
|
||||
Original: {getKindName(publicationKind)}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Targeted Communities */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Users className="size-5" />
|
||||
Targeted Communities ({communities.length})
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{communities.map((community) => (
|
||||
<CommunityCard
|
||||
key={community.pubkey}
|
||||
pubkey={community.pubkey}
|
||||
relay={community.relay}
|
||||
onOpen={() => openCommunity(community.pubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Original Publication */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Original Publication</h2>
|
||||
{originalEvent ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<DetailKindRenderer event={originalEvent} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : pointer ? (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Loading original publication
|
||||
{publicationKind && ` (kind ${publicationKind})`}...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground">
|
||||
Original publication reference not found
|
||||
</p>
|
||||
{eventId && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Event ID: <code>{eventId}</code>
|
||||
</p>
|
||||
)}
|
||||
{eventAddress && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Address: <code>{eventAddress}</code>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component for displaying a targeted community
|
||||
*/
|
||||
function CommunityCard({
|
||||
pubkey,
|
||||
relay,
|
||||
onOpen,
|
||||
}: {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const profile = useProfile(pubkey);
|
||||
const displayName = getDisplayName(pubkey, profile);
|
||||
|
||||
return (
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{displayName}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onOpen}>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<UserName pubkey={pubkey} className="text-xs" />
|
||||
</p>
|
||||
{relay && (
|
||||
<p className="text-xs text-muted-foreground mt-1 truncate">
|
||||
Relay: {relay}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
106
src/components/nostr/kinds/TargetedPublicationRenderer.tsx
Normal file
106
src/components/nostr/kinds/TargetedPublicationRenderer.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Target, Users } from "lucide-react";
|
||||
import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
|
||||
import { KindRenderer } from "./index";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import {
|
||||
getTargetedPublicationEventId,
|
||||
getTargetedPublicationAddress,
|
||||
getTargetedPublicationKind,
|
||||
getTargetedCommunityPubkeys,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { parseAddressPointer } from "@/lib/nip89-helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 30222 - Targeted Publication
|
||||
* Displays the original publication with badges for targeted communities
|
||||
*/
|
||||
export function TargetedPublicationRenderer({
|
||||
event,
|
||||
depth = 0,
|
||||
}: BaseEventProps) {
|
||||
// Get the original publication reference
|
||||
const eventId = getTargetedPublicationEventId(event);
|
||||
const eventAddress = getTargetedPublicationAddress(event);
|
||||
const publicationKind = getTargetedPublicationKind(event);
|
||||
|
||||
// Build pointer for the original event
|
||||
let pointer: Parameters<typeof useNostrEvent>[0] = undefined;
|
||||
if (eventId) {
|
||||
pointer = { id: eventId };
|
||||
} else if (eventAddress) {
|
||||
const parsed = parseAddressPointer(eventAddress);
|
||||
if (parsed) {
|
||||
pointer = {
|
||||
kind: parsed.kind,
|
||||
pubkey: parsed.pubkey,
|
||||
identifier: parsed.identifier,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the original publication
|
||||
const originalEvent = useNostrEvent(pointer, event);
|
||||
|
||||
// Get targeted communities
|
||||
const communityPubkeys = getTargetedCommunityPubkeys(event);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header with target icon and communities */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Target className="size-4" />
|
||||
<span>Targeted to:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{communityPubkeys.slice(0, 5).map((pubkey) => (
|
||||
<CommunityBadge key={pubkey} pubkey={pubkey} />
|
||||
))}
|
||||
{communityPubkeys.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{communityPubkeys.length - 5} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original publication */}
|
||||
{originalEvent ? (
|
||||
<div className="border-l-2 border-muted pl-3 -ml-1">
|
||||
<KindRenderer event={originalEvent} depth={depth + 1} />
|
||||
</div>
|
||||
) : pointer ? (
|
||||
<div className="border-l-2 border-muted pl-3 -ml-1 py-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Loading original publication
|
||||
{publicationKind && ` (kind ${publicationKind})`}...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-l-2 border-muted pl-3 -ml-1 py-2 text-sm text-muted-foreground">
|
||||
Original publication reference not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge component showing a community with its name
|
||||
*/
|
||||
function CommunityBadge({ pubkey }: { pubkey: string }) {
|
||||
const profile = useProfile(pubkey);
|
||||
const displayName = getDisplayName(pubkey, profile);
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Users className="size-3" />
|
||||
{displayName}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,10 @@ import { Kind30023Renderer } from "./ArticleRenderer";
|
||||
import { Kind30023DetailRenderer } from "./ArticleDetailRenderer";
|
||||
import { CommunityNIPRenderer } from "./CommunityNIPRenderer";
|
||||
import { CommunityNIPDetailRenderer } from "./CommunityNIPDetailRenderer";
|
||||
import { CommunikeyRenderer } from "./CommunikeyRenderer";
|
||||
import { CommunikeyDetailRenderer } from "./CommunikeyDetailRenderer";
|
||||
import { TargetedPublicationRenderer } from "./TargetedPublicationRenderer";
|
||||
import { TargetedPublicationDetailRenderer } from "./TargetedPublicationDetailRenderer";
|
||||
import { RepositoryRenderer } from "./RepositoryRenderer";
|
||||
import { RepositoryDetailRenderer } from "./RepositoryDetailRenderer";
|
||||
import { RepositoryStateRenderer } from "./RepositoryStateRenderer";
|
||||
@@ -111,6 +115,8 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
30618: RepositoryStateRenderer, // Repository State (NIP-34)
|
||||
30777: SpellbookRenderer, // Spellbook (Grimoire)
|
||||
30817: CommunityNIPRenderer, // Community NIP
|
||||
10222: CommunikeyRenderer, // Communikey (Community Definition)
|
||||
30222: TargetedPublicationRenderer, // Targeted Publication (Communikeys)
|
||||
31922: CalendarDateEventRenderer, // Date-Based Calendar Event (NIP-52)
|
||||
31923: CalendarTimeEventRenderer, // Time-Based Calendar Event (NIP-52)
|
||||
31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89)
|
||||
@@ -177,6 +183,8 @@ const detailRenderers: Record<
|
||||
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
|
||||
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
|
||||
30817: CommunityNIPDetailRenderer, // Community NIP Detail
|
||||
10222: CommunikeyDetailRenderer, // Communikey Detail (Community Definition)
|
||||
30222: TargetedPublicationDetailRenderer, // Targeted Publication Detail (Communikeys)
|
||||
31922: CalendarDateEventDetailRenderer, // Date-Based Calendar Event Detail (NIP-52)
|
||||
31923: CalendarTimeEventDetailRenderer, // Time-Based Calendar Event Detail (NIP-52)
|
||||
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
Smile,
|
||||
Star,
|
||||
Tag,
|
||||
Target,
|
||||
Trash2,
|
||||
User,
|
||||
UserCheck,
|
||||
@@ -849,6 +850,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "66",
|
||||
icon: Activity,
|
||||
},
|
||||
10222: {
|
||||
kind: 10222,
|
||||
name: "Communikey",
|
||||
description: "Community Definition",
|
||||
nip: "Communikeys",
|
||||
icon: Users,
|
||||
},
|
||||
10317: {
|
||||
kind: 10317,
|
||||
name: "Grasp List",
|
||||
@@ -1191,6 +1199,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "66",
|
||||
icon: Compass,
|
||||
},
|
||||
30222: {
|
||||
kind: 30222,
|
||||
name: "Targeted Publication",
|
||||
description: "Publication targeted at communities",
|
||||
nip: "Communikeys",
|
||||
icon: Target,
|
||||
},
|
||||
30267: {
|
||||
kind: 30267,
|
||||
name: "App Collection",
|
||||
|
||||
@@ -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)
|
||||
|
||||
437
src/lib/chat/adapters/communikeys-adapter.ts
Normal file
437
src/lib/chat/adapters/communikeys-adapter.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { map, first } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
LoadMessagesOptions,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { publishEventToRelays } from "@/services/hub";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import { getProfileContent } from "applesauce-core/helpers";
|
||||
import {
|
||||
getCommunikeyRelays,
|
||||
getCommunikeyDescription,
|
||||
getCommunikeyContentSections,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { isValidHexPubkey, normalizeHex } from "@/lib/nostr-validation";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
const COMMUNIKEY_KIND = 10222;
|
||||
|
||||
/**
|
||||
* Communikeys Adapter - Community-Based Groups
|
||||
*
|
||||
* Features:
|
||||
* - Any npub can become a community
|
||||
* - Community config from kind:10222, profile from kind:0
|
||||
* - Chat messages use kind:9 with h-tag containing community pubkey
|
||||
* - Relays specified in community config (kind:10222 r-tags)
|
||||
*
|
||||
* Identifier formats:
|
||||
* - npub1... (any npub can be a community)
|
||||
* - nprofile1... (with relay hints)
|
||||
* - hex pubkey (64 chars)
|
||||
*/
|
||||
export class CommunikeysAdapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "communikeys" as const;
|
||||
readonly type = "group" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts npub, nprofile, or hex pubkey
|
||||
* Returns null if identifier doesn't look like a pubkey
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try npub format
|
||||
if (input.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "npub") {
|
||||
return {
|
||||
type: "communikey",
|
||||
value: decoded.data,
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try nprofile format (with relay hints)
|
||||
if (input.startsWith("nprofile1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "nprofile") {
|
||||
return {
|
||||
type: "communikey",
|
||||
value: decoded.data.pubkey,
|
||||
relays: decoded.data.relays || [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Try hex pubkey (64 chars)
|
||||
if (isValidHexPubkey(input)) {
|
||||
return {
|
||||
type: "communikey",
|
||||
value: normalizeHex(input),
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from community pubkey
|
||||
* Fetches kind:0 profile and kind:10222 community config
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
const communityPubkey = identifier.value;
|
||||
const hintRelays = identifier.relays || [];
|
||||
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Communikeys] Fetching community config for ${communityPubkey.slice(0, 8)}...`,
|
||||
);
|
||||
|
||||
// Use hint relays + aggregators for fetching metadata
|
||||
const fetchRelays = [...hintRelays, ...AGGREGATOR_RELAYS.slice(0, 3)];
|
||||
|
||||
// Fetch community config (kind:10222) and profile (kind:0)
|
||||
const filter: Filter = {
|
||||
kinds: [0, COMMUNIKEY_KIND],
|
||||
authors: [communityPubkey],
|
||||
limit: 2,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription(fetchRelays, [filter], { eventStore });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log("[Communikeys] Metadata fetch timeout");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
clearTimeout(timeout);
|
||||
console.log(`[Communikeys] Got ${events.length} metadata events`);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("[Communikeys] Metadata fetch error:", err);
|
||||
sub.unsubscribe();
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Extract profile and community config
|
||||
const profileEvent = events.find((e) => e.kind === 0);
|
||||
const configEvent = events.find((e) => e.kind === COMMUNIKEY_KIND);
|
||||
|
||||
// Parse profile
|
||||
const profile = profileEvent ? getProfileContent(profileEvent) : null;
|
||||
const displayName =
|
||||
profile?.display_name ||
|
||||
profile?.name ||
|
||||
`${communityPubkey.slice(0, 8)}...`;
|
||||
|
||||
// Parse community config
|
||||
let communityRelays: string[] = [];
|
||||
let description: string | undefined;
|
||||
|
||||
if (configEvent) {
|
||||
communityRelays = getCommunikeyRelays(configEvent);
|
||||
description = getCommunikeyDescription(configEvent) || profile?.about;
|
||||
|
||||
// Check if chat is supported (kind 9 in content sections)
|
||||
const sections = getCommunikeyContentSections(configEvent);
|
||||
const hasChat = sections.some((s) => s.kinds.includes(9));
|
||||
if (!hasChat) {
|
||||
console.warn(
|
||||
"[Communikeys] Community does not have chat enabled (kind 9)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Communikeys] Community: ${displayName}, relays: ${communityRelays.length}`,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `communikeys:${communityPubkey}`,
|
||||
type: "group",
|
||||
protocol: "communikeys",
|
||||
title: displayName,
|
||||
participants: [], // Could fetch from badge holders later
|
||||
metadata: {
|
||||
communityPubkey,
|
||||
communityRelays,
|
||||
description,
|
||||
icon: profile?.picture,
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a community
|
||||
* Uses kind:9 messages with h-tag = community pubkey
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const communityPubkey = conversation.metadata?.communityPubkey;
|
||||
const communityRelays = conversation.metadata?.communityRelays || [];
|
||||
|
||||
if (!communityPubkey) {
|
||||
throw new Error("Community pubkey required");
|
||||
}
|
||||
|
||||
// Use community relays + aggregators for fetching
|
||||
const fetchRelays =
|
||||
communityRelays.length > 0
|
||||
? communityRelays
|
||||
: AGGREGATOR_RELAYS.slice(0, 3);
|
||||
|
||||
console.log(
|
||||
`[Communikeys] Loading messages for ${communityPubkey.slice(0, 8)}... from ${fetchRelays.length} relays`,
|
||||
);
|
||||
|
||||
// Subscribe to chat messages (kind 9) with h-tag = community pubkey
|
||||
const filter: Filter = {
|
||||
kinds: [9],
|
||||
"#h": [communityPubkey],
|
||||
limit: options?.limit || 50,
|
||||
};
|
||||
|
||||
if (options?.before) {
|
||||
filter.until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filter.since = options.after;
|
||||
}
|
||||
|
||||
// Start persistent subscription
|
||||
pool.subscription(fetchRelays, [filter], { eventStore }).subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
console.log("[Communikeys] EOSE received for messages");
|
||||
} else {
|
||||
console.log(
|
||||
`[Communikeys] Received message: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Return observable from EventStore
|
||||
return eventStore.timeline(filter).pipe(
|
||||
map((events) => {
|
||||
console.log(`[Communikeys] Timeline has ${events.length} messages`);
|
||||
return events
|
||||
.map((event) => this.eventToMessage(event, conversation.id))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
_conversation: Conversation,
|
||||
_before: number,
|
||||
): Promise<Message[]> {
|
||||
// Pagination to be implemented
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the community
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activePubkey || !activeSigner) {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
const communityPubkey = conversation.metadata?.communityPubkey;
|
||||
const communityRelays = conversation.metadata?.communityRelays || [];
|
||||
|
||||
if (!communityPubkey) {
|
||||
throw new Error("Community pubkey required");
|
||||
}
|
||||
|
||||
// Use community relays for publishing
|
||||
const publishRelays =
|
||||
communityRelays.length > 0
|
||||
? communityRelays
|
||||
: AGGREGATOR_RELAYS.slice(0, 3);
|
||||
|
||||
// Create event with h-tag = community pubkey
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [["h", communityPubkey]];
|
||||
|
||||
if (options?.replyTo) {
|
||||
// Use q-tag for replies (same as NIP-29 and NIP-C7)
|
||||
tags.push(["q", options.replyTo]);
|
||||
}
|
||||
|
||||
// Add NIP-30 emoji tags
|
||||
if (options?.emojiTags) {
|
||||
for (const emoji of options.emojiTags) {
|
||||
tags.push(["emoji", emoji.shortcode, emoji.url]);
|
||||
}
|
||||
}
|
||||
|
||||
// Use kind 9 for chat messages
|
||||
const draft = await factory.build({ kind: 9, content, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to community relays
|
||||
await publishEventToRelays(event, publishRelays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: false, // kind 9 messages are public
|
||||
supportsThreading: true, // q-tag replies
|
||||
supportsModeration: false, // badge-based, not relay-enforced
|
||||
supportsRoles: true, // badge-based roles
|
||||
supportsGroupManagement: false, // no join/leave required
|
||||
canCreateConversations: false, // communities are created by publishing kind:10222
|
||||
requiresRelay: true, // needs community relays
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// Check EventStore first
|
||||
const cachedEvent = await eventStore
|
||||
.event(eventId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (cachedEvent) {
|
||||
return cachedEvent;
|
||||
}
|
||||
|
||||
// Fetch from community relays
|
||||
const communityRelays = conversation.metadata?.communityRelays || [];
|
||||
const fetchRelays =
|
||||
communityRelays.length > 0
|
||||
? communityRelays
|
||||
: AGGREGATOR_RELAYS.slice(0, 3);
|
||||
|
||||
console.log(
|
||||
`[Communikeys] Fetching reply message ${eventId.slice(0, 8)}...`,
|
||||
);
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription(fetchRelays, [filter], { eventStore });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(
|
||||
`[Communikeys] Reply message fetch timeout for ${eventId.slice(0, 8)}...`,
|
||||
);
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
const sub = obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`[Communikeys] Reply message fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert Nostr event to Message
|
||||
*/
|
||||
private eventToMessage(event: NostrEvent, conversationId: string): Message {
|
||||
// Look for reply q-tags
|
||||
const qTags = getTagValues(event, "q");
|
||||
const replyTo = qTags[0];
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "communikeys",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
}
|
||||
83
src/lib/communikey-parser.ts
Normal file
83
src/lib/communikey-parser.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { isNip05, resolveNip05 } from "./nip05";
|
||||
import { isValidHexPubkey, normalizeHex } from "./nostr-validation";
|
||||
|
||||
export interface ParsedCommunikeyCommand {
|
||||
pubkey: string;
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse COMMUNIKEY command arguments into a community pubkey
|
||||
* Supports:
|
||||
* - npub1... (bech32 npub - any npub can be a community)
|
||||
* - nprofile1... (bech32 nprofile with relay hints)
|
||||
* - abc123... (64-char hex pubkey)
|
||||
* - user@domain.com (NIP-05 identifier)
|
||||
* - domain.com (bare domain, resolved as _@domain.com)
|
||||
*
|
||||
* Note: ncommunity format is planned but not yet implemented
|
||||
*/
|
||||
export async function parseCommunikeyCommand(
|
||||
args: string[],
|
||||
activeAccountPubkey?: string,
|
||||
): Promise<ParsedCommunikeyCommand> {
|
||||
const identifier = args[0];
|
||||
|
||||
if (!identifier) {
|
||||
throw new Error("Community identifier required");
|
||||
}
|
||||
|
||||
// Handle $me alias (view your own community profile)
|
||||
if (identifier.toLowerCase() === "$me") {
|
||||
return {
|
||||
pubkey: activeAccountPubkey || "$me",
|
||||
};
|
||||
}
|
||||
|
||||
// Try bech32 decode first (npub, nprofile)
|
||||
if (identifier.startsWith("npub") || identifier.startsWith("nprofile")) {
|
||||
try {
|
||||
const decoded = nip19.decode(identifier);
|
||||
|
||||
if (decoded.type === "npub") {
|
||||
// npub1... -> pubkey
|
||||
return {
|
||||
pubkey: decoded.data,
|
||||
};
|
||||
}
|
||||
|
||||
if (decoded.type === "nprofile") {
|
||||
// nprofile1... -> pubkey with relay hints
|
||||
return {
|
||||
pubkey: decoded.data.pubkey,
|
||||
relays: decoded.data.relays,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid bech32 identifier: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a hex pubkey
|
||||
if (isValidHexPubkey(identifier)) {
|
||||
return {
|
||||
pubkey: normalizeHex(identifier),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a NIP-05 identifier (user@domain.com)
|
||||
if (isNip05(identifier)) {
|
||||
const pubkey = await resolveNip05(identifier);
|
||||
if (!pubkey) {
|
||||
throw new Error(
|
||||
`Failed to resolve NIP-05 identifier: ${identifier}. Please check the identifier and try again.`,
|
||||
);
|
||||
}
|
||||
return { pubkey };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Invalid community identifier. Supported formats: npub1..., nprofile1..., hex pubkey, user@domain.com, or domain.com",
|
||||
);
|
||||
}
|
||||
362
src/lib/communikeys-helpers.ts
Normal file
362
src/lib/communikeys-helpers.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Communikeys Helper Functions
|
||||
* Utility functions for parsing Communikey events (kind 10222 and 30222)
|
||||
*
|
||||
* Kind 10222: Community Definition Event
|
||||
* Kind 30222: Targeted Publication Event
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ContentSection {
|
||||
name: string;
|
||||
kinds: number[];
|
||||
fee?: { amount: number; unit: string };
|
||||
exclusive?: boolean;
|
||||
badgeRequirement?: string; // "a" tag value like "30009:pubkey:badge-id"
|
||||
}
|
||||
|
||||
export interface CommunikeyConfig {
|
||||
relays: string[];
|
||||
blossomServers: string[];
|
||||
mints: string[];
|
||||
contentSections: ContentSection[];
|
||||
description?: string;
|
||||
tos?: { id: string; relay?: string };
|
||||
location?: string;
|
||||
geohash?: string;
|
||||
}
|
||||
|
||||
export interface TargetedCommunity {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function getTagValues(event: NostrEvent, tagName: string): string[] {
|
||||
return event.tags.filter((t) => t[0] === tagName).map((t) => t[1]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Community Definition Event Helpers (Kind 10222)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all relay URLs from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of relay URLs
|
||||
*/
|
||||
export function getCommunikeyRelays(event: NostrEvent): string[] {
|
||||
return getTagValues(event, "r");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main (first) relay URL from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Main relay URL or undefined
|
||||
*/
|
||||
export function getCommunikeyMainRelay(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "r");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blossom server URLs from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of blossom server URLs
|
||||
*/
|
||||
export function getCommunikeyBlossomServers(event: NostrEvent): string[] {
|
||||
return getTagValues(event, "blossom");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ecash mint URLs from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of mint URLs with their protocols
|
||||
*/
|
||||
export function getCommunikeyMints(
|
||||
event: NostrEvent,
|
||||
): Array<{ url: string; protocol?: string }> {
|
||||
return event.tags
|
||||
.filter((t) => t[0] === "mint")
|
||||
.map((t) => ({ url: t[1], protocol: t[2] }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description override from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Description string or undefined
|
||||
*/
|
||||
export function getCommunikeyDescription(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "description");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the terms of service reference from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns ToS object with event ID and optional relay, or undefined
|
||||
*/
|
||||
export function getCommunikeyTos(
|
||||
event: NostrEvent,
|
||||
): { id: string; relay?: string } | undefined {
|
||||
const tosTag = event.tags.find((t) => t[0] === "tos");
|
||||
if (!tosTag) return undefined;
|
||||
return { id: tosTag[1], relay: tosTag[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the location from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Location string or undefined
|
||||
*/
|
||||
export function getCommunikeyLocation(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "location");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the geohash from a community definition event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Geohash string or undefined
|
||||
*/
|
||||
export function getCommunikeyGeohash(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "g");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content sections from a community definition event
|
||||
* Content sections are defined by sequential tags starting with ["content", "name"]
|
||||
* followed by k, fee, exclusive, and a (badge) tags that apply to that section
|
||||
*
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Array of parsed content sections
|
||||
*/
|
||||
export function getCommunikeyContentSections(
|
||||
event: NostrEvent,
|
||||
): ContentSection[] {
|
||||
const sections: ContentSection[] = [];
|
||||
let currentSection: ContentSection | null = null;
|
||||
|
||||
for (const tag of event.tags) {
|
||||
const [tagName, ...values] = tag;
|
||||
|
||||
if (tagName === "content") {
|
||||
// Start a new section
|
||||
if (currentSection) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
currentSection = {
|
||||
name: values[0] || "Unnamed",
|
||||
kinds: [],
|
||||
};
|
||||
} else if (currentSection) {
|
||||
// Only process these tags if we're in a content section
|
||||
switch (tagName) {
|
||||
case "k":
|
||||
// Add kind to current section
|
||||
const kind = parseInt(values[0], 10);
|
||||
if (!isNaN(kind)) {
|
||||
currentSection.kinds.push(kind);
|
||||
}
|
||||
break;
|
||||
case "fee":
|
||||
// Fee format: ["fee", "amount", "unit"]
|
||||
const amount = parseInt(values[0], 10);
|
||||
if (!isNaN(amount)) {
|
||||
currentSection.fee = { amount, unit: values[1] || "sat" };
|
||||
}
|
||||
break;
|
||||
case "exclusive":
|
||||
currentSection.exclusive = values[0] === "true";
|
||||
break;
|
||||
case "a":
|
||||
// Badge requirement - only set if it looks like a badge address (30009:...)
|
||||
if (values[0]?.startsWith("30009:")) {
|
||||
currentSection.badgeRequirement = values[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full community configuration from a kind 10222 event
|
||||
* @param event Community event (kind 10222)
|
||||
* @returns Parsed community configuration
|
||||
*/
|
||||
export function getCommunikeyConfig(event: NostrEvent): CommunikeyConfig {
|
||||
return {
|
||||
relays: getCommunikeyRelays(event),
|
||||
blossomServers: getCommunikeyBlossomServers(event),
|
||||
mints: getCommunikeyMints(event).map((m) => m.url),
|
||||
contentSections: getCommunikeyContentSections(event),
|
||||
description: getCommunikeyDescription(event),
|
||||
tos: getCommunikeyTos(event),
|
||||
location: getCommunikeyLocation(event),
|
||||
geohash: getCommunikeyGeohash(event),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a kind is supported in any content section of the community
|
||||
* @param event Community event (kind 10222)
|
||||
* @param kind Event kind to check
|
||||
* @returns True if the kind is supported
|
||||
*/
|
||||
export function isCommunikeyKindSupported(
|
||||
event: NostrEvent,
|
||||
kind: number,
|
||||
): boolean {
|
||||
const sections = getCommunikeyContentSections(event);
|
||||
return sections.some((s) => s.kinds.includes(kind));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content section that supports a specific kind
|
||||
* @param event Community event (kind 10222)
|
||||
* @param kind Event kind to find
|
||||
* @returns The content section supporting this kind, or undefined
|
||||
*/
|
||||
export function getCommunikeySectionForKind(
|
||||
event: NostrEvent,
|
||||
kind: number,
|
||||
): ContentSection | undefined {
|
||||
const sections = getCommunikeyContentSections(event);
|
||||
return sections.find((s) => s.kinds.includes(kind));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Targeted Publication Event Helpers (Kind 30222)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the original event ID from a targeted publication event
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Event ID or undefined
|
||||
*/
|
||||
export function getTargetedPublicationEventId(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "e");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original event address from a targeted publication event
|
||||
* Used for addressable events (kinds 30000-39999)
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Event address or undefined
|
||||
*/
|
||||
export function getTargetedPublicationAddress(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "a");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original publication's kind from a targeted publication event
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Event kind or undefined
|
||||
*/
|
||||
export function getTargetedPublicationKind(
|
||||
event: NostrEvent,
|
||||
): number | undefined {
|
||||
const kindStr = getTagValue(event, "k");
|
||||
if (!kindStr) return undefined;
|
||||
const kind = parseInt(kindStr, 10);
|
||||
return isNaN(kind) ? undefined : kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all targeted communities from a targeted publication event
|
||||
* Communities are specified via p tags with optional r tags for relay hints
|
||||
*
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Array of targeted community objects with pubkey and optional relay
|
||||
*/
|
||||
export function getTargetedCommunities(event: NostrEvent): TargetedCommunity[] {
|
||||
const communities: TargetedCommunity[] = [];
|
||||
const relayHints: string[] = [];
|
||||
|
||||
// Collect relay hints
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "r" && tag[1]) {
|
||||
relayHints.push(tag[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect community pubkeys and pair with relays
|
||||
let relayIndex = 0;
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "p" && tag[1]) {
|
||||
communities.push({
|
||||
pubkey: tag[1],
|
||||
relay: relayHints[relayIndex],
|
||||
});
|
||||
relayIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return communities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get just the community pubkeys from a targeted publication event
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @returns Array of community pubkeys
|
||||
*/
|
||||
export function getTargetedCommunityPubkeys(event: NostrEvent): string[] {
|
||||
return getTagValues(event, "p");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a publication targets a specific community
|
||||
* @param event Targeted publication event (kind 30222)
|
||||
* @param communityPubkey The community pubkey to check
|
||||
* @returns True if the publication targets this community
|
||||
*/
|
||||
export function isTargetedToCommunity(
|
||||
event: NostrEvent,
|
||||
communityPubkey: string,
|
||||
): boolean {
|
||||
return getTargetedCommunityPubkeys(event).includes(communityPubkey);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Community-Exclusive Content Helpers (h tag)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the community pubkey from an exclusive content event (e.g., kind 9 chat)
|
||||
* @param event Content event with h tag
|
||||
* @returns Community pubkey or undefined
|
||||
*/
|
||||
export function getExclusiveCommunityPubkey(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "h");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is exclusive community content
|
||||
* @param event Any event
|
||||
* @returns True if the event has an h tag (community-exclusive)
|
||||
*/
|
||||
export function isExclusiveCommunityContent(event: NostrEvent): boolean {
|
||||
return event.tags.some((t) => t[0] === "h");
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type AppId =
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "chat"
|
||||
| "communikey"
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "win";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseProfileCommand } from "@/lib/profile-parser";
|
||||
import { parseRelayCommand } from "@/lib/relay-parser";
|
||||
import { resolveNip05Batch } from "@/lib/nip05";
|
||||
import { parseChatCommand } from "@/lib/chat-parser";
|
||||
import { parseCommunikeyCommand } from "@/lib/communikey-parser";
|
||||
|
||||
export interface ManPageEntry {
|
||||
name: string;
|
||||
@@ -350,20 +351,22 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
section: "1",
|
||||
synopsis: "chat <identifier>",
|
||||
description:
|
||||
"Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups and NIP-53 live activity chat. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event to join its chat.",
|
||||
"Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, Communikey communities, and NIP-53 live activity chat. NIP-29 groups use 'relay'group-id' format. Communikey communities use npub, nprofile, or hex pubkey. NIP-53 live activities use naddr of a kind 30311 event.",
|
||||
options: [
|
||||
{
|
||||
flag: "<identifier>",
|
||||
description:
|
||||
"NIP-29 group (relay'group-id) or NIP-53 live activity (naddr1...)",
|
||||
"NIP-29: relay'group-id | Communikey: npub/nprofile/hex | NIP-53: naddr (kind 30311)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"chat relay.example.com'bitcoin-dev Join NIP-29 relay group",
|
||||
"chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol",
|
||||
"chat naddr1... Join NIP-53 live activity chat",
|
||||
"chat relay.example.com'bitcoin-dev NIP-29 relay group",
|
||||
"chat wss://nos.lol'welcome NIP-29 with explicit protocol",
|
||||
"chat npub1... Communikey community",
|
||||
"chat nprofile1... Communikey with relay hints",
|
||||
"chat naddr1... NIP-53 live activity chat",
|
||||
],
|
||||
seeAlso: ["profile", "open", "req", "live"],
|
||||
seeAlso: ["profile", "communikey", "open", "req", "live"],
|
||||
appId: "chat",
|
||||
category: "Nostr",
|
||||
argParser: async (args: string[]) => {
|
||||
@@ -402,6 +405,33 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
communikey: {
|
||||
name: "communikey",
|
||||
section: "1",
|
||||
synopsis: "communikey <identifier>",
|
||||
description:
|
||||
"View a Communikey community. Communikeys allow any existing npub to become a community with its own relays, content sections, and configuration. Accepts npub, nprofile, hex pubkeys, NIP-05 identifiers, and the $me alias.",
|
||||
options: [
|
||||
{
|
||||
flag: "<identifier>",
|
||||
description:
|
||||
"Community identifier in any supported format (npub, nprofile, hex pubkey, NIP-05)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"communikey npub1... View community by npub",
|
||||
"communikey nprofile1... View community with relay hints",
|
||||
"communikey community@example.com View community by NIP-05",
|
||||
"communikey $me View your own community",
|
||||
],
|
||||
seeAlso: ["profile", "chat", "req"],
|
||||
appId: "communikey",
|
||||
category: "Nostr",
|
||||
argParser: async (args: string[], activeAccountPubkey?: string) => {
|
||||
const parsed = await parseCommunikeyCommand(args, activeAccountPubkey);
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
encode: {
|
||||
name: "encode",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user