mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-15 01:46:53 +02:00
feat: Add NIP-28 public chat channel support
Implements full NIP-28 public chat channel support alongside existing NIP-29 relay groups and NIP-53 live activity chat. ## Implementation **Adapter** (src/lib/chat/adapters/nip-28-adapter.ts): - Multi-relay channel coordination (no single authority) - Parses note1/nevent1 identifiers (kind 40 creation events) - Resolves channel metadata from kind 41 events - Loads kind 42 messages with NIP-10 threading - Supports reply threading via marked e-tags (root/reply) - Publishes messages with proper NIP-10 tag structure **Feed Renderers**: - Kind40Renderer (ChannelCreationRenderer) - Channel creation events with "Open Channel" button - Kind41Renderer (ChannelMetadataRenderer) - Channel metadata updates - Kind42Renderer (ChannelMessageRenderer) - Channel messages with threading and channel context **Detail Renderers**: - Kind40DetailRenderer (ChannelCreationDetailRenderer) - Full channel info with metadata, relays, and open button - Kind42DetailRenderer (ChannelMessageDetailRenderer) - Message with thread context and channel info **Integration**: - Updated renderer registry (src/components/nostr/kinds/index.tsx) - Enabled Nip28Adapter in chat-parser.ts (Phase 3 priority) - Added kind 42 to CHAT_KINDS constant - Updated chat command documentation with NIP-28 examples **Tests**: - Comprehensive unit tests for Nip28Adapter identifier parsing - Protocol and capability verification ## Usage ```bash chat note1xyz... # Open channel by event ID chat nevent1xyz... # Open channel with relay hints ``` ## Architecture Notes NIP-28 vs NIP-29: - NIP-28: Multi-relay, open participation, client-side moderation - NIP-29: Single relay authority, membership enforcement, server-side moderation - Both coexist - users choose based on censorship resistance vs moderation needs Threading: - Uses NIP-10 marked e-tags (root → kind 40, reply → parent message) - Different from NIP-29's simpler q-tag replies - Fully integrated with ChatViewer's reply preview system
This commit is contained in:
154
src/components/nostr/kinds/ChannelCreationDetailRenderer.tsx
Normal file
154
src/components/nostr/kinds/ChannelCreationDetailRenderer.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { Hash, Calendar, Users, ExternalLink } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import Timestamp from "../Timestamp";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ChannelCreationDetailRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 40 Detail View - Full channel information
|
||||
* Shows channel creation details with metadata and open button
|
||||
*/
|
||||
export function ChannelCreationDetailRenderer({
|
||||
event,
|
||||
}: ChannelCreationDetailRendererProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const channelName = event.content || `Channel ${event.id.slice(0, 8)}`;
|
||||
|
||||
// Fetch the latest kind 41 metadata for this channel
|
||||
const metadataEvent = use$(
|
||||
() =>
|
||||
eventStore.timeline({
|
||||
kinds: [41],
|
||||
authors: [event.pubkey],
|
||||
"#e": [event.id],
|
||||
limit: 1,
|
||||
}),
|
||||
[event.id, event.pubkey],
|
||||
)[0];
|
||||
|
||||
// Parse metadata if available
|
||||
const metadata = useMemo(() => {
|
||||
if (!metadataEvent) return null;
|
||||
try {
|
||||
return JSON.parse(metadataEvent.content) as {
|
||||
name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
relays?: string[];
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [metadataEvent]);
|
||||
|
||||
// Extract relay hints from event
|
||||
const relayHints = event.tags
|
||||
.filter((t) => t[0] === "r" && t[1])
|
||||
.map((t) => t[1]);
|
||||
|
||||
const handleOpenChannel = () => {
|
||||
const identifier =
|
||||
relayHints.length > 0
|
||||
? nip19.neventEncode({ id: event.id, relays: relayHints })
|
||||
: nip19.noteEncode(event.id);
|
||||
|
||||
addWindow(
|
||||
"chat",
|
||||
{ protocol: "nip-28", identifier },
|
||||
`#${metadata?.name || channelName}`,
|
||||
);
|
||||
};
|
||||
|
||||
const title = metadata?.name || channelName;
|
||||
const description = metadata?.about;
|
||||
const picture = metadata?.picture;
|
||||
const metadataRelays = metadata?.relays || [];
|
||||
const allRelays = Array.from(new Set([...relayHints, ...metadataRelays]));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background overflow-y-auto">
|
||||
{/* Header Image */}
|
||||
{picture && (
|
||||
<div className="flex-shrink-0 aspect-video bg-muted">
|
||||
<img
|
||||
src={picture}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel Info Section */}
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
{/* Title */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Hash className="size-8 text-muted-foreground flex-shrink-0 mt-1" />
|
||||
<h1 className="text-2xl font-bold text-balance">{title}</h1>
|
||||
</div>
|
||||
|
||||
{/* Creator */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="size-4" />
|
||||
<span>Created by</span>
|
||||
<UserName pubkey={event.pubkey} className="text-accent" />
|
||||
</div>
|
||||
|
||||
{/* Created Date */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="size-4" />
|
||||
<Timestamp timestamp={event.created_at} format="long" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className="space-y-2">
|
||||
<Label>About</Label>
|
||||
<p className="text-base text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relays */}
|
||||
{allRelays.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Relays ({allRelays.length})</Label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{allRelays.map((relay) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground font-mono bg-muted/30 px-2 py-1 rounded"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
<span className="truncate">{relay}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open Channel Button */}
|
||||
<Button onClick={handleOpenChannel} className="w-full">
|
||||
Open Channel
|
||||
</Button>
|
||||
|
||||
{/* Metadata Status */}
|
||||
{metadataEvent && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Last updated <Timestamp timestamp={metadataEvent.created_at} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/nostr/kinds/ChannelCreationRenderer.tsx
Normal file
55
src/components/nostr/kinds/ChannelCreationRenderer.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Hash, Users } from "lucide-react";
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { UserName } from "../UserName";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
/**
|
||||
* Kind 40 Renderer - Channel Creation (Feed View)
|
||||
* NIP-28 public chat channel creation event
|
||||
*/
|
||||
export function ChannelCreationRenderer({ event }: BaseEventProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const channelName = event.content || `Channel ${event.id.slice(0, 8)}`;
|
||||
|
||||
const handleOpenChannel = () => {
|
||||
// Create nevent with relay hints from event
|
||||
const relayHints = event.tags
|
||||
.filter((t) => t[0] === "r" && t[1])
|
||||
.map((t) => t[1]);
|
||||
|
||||
const identifier =
|
||||
relayHints.length > 0
|
||||
? nip19.neventEncode({ id: event.id, relays: relayHints })
|
||||
: nip19.noteEncode(event.id);
|
||||
|
||||
addWindow("chat", { protocol: "nip-28", identifier }, `#${channelName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Hash className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{channelName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
<span>Created by</span>
|
||||
<UserName pubkey={event.pubkey} className="text-accent" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleOpenChannel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
>
|
||||
Open Channel
|
||||
</Button>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
133
src/components/nostr/kinds/ChannelMessageDetailRenderer.tsx
Normal file
133
src/components/nostr/kinds/ChannelMessageDetailRenderer.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { Hash, MessageCircle, Calendar } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { RichText } from "../RichText";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getNip10References } from "applesauce-common/helpers/threading";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import Timestamp from "../Timestamp";
|
||||
|
||||
interface ChannelMessageDetailRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind 42 Detail View - Full channel message with thread context
|
||||
* Shows the message with its channel and reply chain
|
||||
*/
|
||||
export function ChannelMessageDetailRenderer({
|
||||
event,
|
||||
}: ChannelMessageDetailRendererProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Parse NIP-10 references
|
||||
const references = getNip10References(event);
|
||||
const rootPointer = references.root?.e;
|
||||
const replyPointer = references.reply?.e;
|
||||
|
||||
// Load channel event (root)
|
||||
const channelEvent = useNostrEvent(rootPointer);
|
||||
|
||||
// Load parent message if this is a reply
|
||||
const parentMessage =
|
||||
replyPointer && replyPointer.id !== rootPointer?.id
|
||||
? useNostrEvent(replyPointer)
|
||||
: null;
|
||||
|
||||
const handleOpenChannel = () => {
|
||||
if (!channelEvent) return;
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer: { id: channelEvent.id } },
|
||||
`#${channelEvent.content || channelEvent.id.slice(0, 8)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenParent = () => {
|
||||
if (!parentMessage) return;
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer: { id: parentMessage.id } },
|
||||
`Message from ${parentMessage.pubkey.slice(0, 8)}...`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background overflow-y-auto p-4 space-y-4">
|
||||
{/* Channel Context */}
|
||||
{channelEvent && (
|
||||
<div className="space-y-2">
|
||||
<Label>Channel</Label>
|
||||
<div className="flex items-center justify-between gap-4 p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Hash className="size-5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium truncate">
|
||||
{channelEvent.content || channelEvent.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleOpenChannel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
View Channel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parent Message (if reply) */}
|
||||
{parentMessage && (
|
||||
<div className="space-y-2">
|
||||
<Label>Replying to</Label>
|
||||
<div className="flex flex-col gap-2 p-3 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MessageCircle className="size-4 text-muted-foreground" />
|
||||
<UserName pubkey={parentMessage.pubkey} className="text-accent" />
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<Timestamp timestamp={parentMessage.created_at} />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-3">
|
||||
<RichText
|
||||
event={parentMessage}
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleOpenParent}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
>
|
||||
View Parent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Author */}
|
||||
<div className="space-y-2">
|
||||
<Label>From</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserName pubkey={event.pubkey} className="text-accent text-base" />
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
<Timestamp timestamp={event.created_at} format="long" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className="space-y-2">
|
||||
<Label>Message</Label>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<RichText event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/components/nostr/kinds/ChannelMessageRenderer.tsx
Normal file
105
src/components/nostr/kinds/ChannelMessageRenderer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { RichText } from "../RichText";
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { UserName } from "../UserName";
|
||||
import { MessageCircle, Hash } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { getNip10References } from "applesauce-common/helpers/threading";
|
||||
import { isValidHexEventId } from "@/lib/nostr-validation";
|
||||
import { InlineReplySkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Kind 42 Renderer - Channel Message (Feed View)
|
||||
* NIP-28 public chat channel message with NIP-10 threading
|
||||
*/
|
||||
export function ChannelMessageRenderer({ event, depth = 0 }: BaseEventProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Parse NIP-10 references for threading
|
||||
const references = getNip10References(event);
|
||||
|
||||
// Root is the channel (kind 40), reply is the parent message
|
||||
const rootPointer = references.root?.e;
|
||||
const replyPointer = references.reply?.e;
|
||||
|
||||
// Only show reply preview if there's a reply pointer
|
||||
const quotedEventId =
|
||||
replyPointer && replyPointer.id !== rootPointer?.id
|
||||
? replyPointer.id
|
||||
: undefined;
|
||||
|
||||
// Pass full event to useNostrEvent for relay hints
|
||||
const parentEvent = useNostrEvent(quotedEventId, event);
|
||||
|
||||
// Load root channel event for context
|
||||
const channelEvent = useNostrEvent(rootPointer);
|
||||
|
||||
const handleQuoteClick = () => {
|
||||
if (!parentEvent || !quotedEventId) return;
|
||||
const pointer = isValidHexEventId(quotedEventId)
|
||||
? {
|
||||
id: quotedEventId,
|
||||
}
|
||||
: quotedEventId;
|
||||
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer },
|
||||
`Reply to ${parentEvent.pubkey.slice(0, 8)}...`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChannelClick = () => {
|
||||
if (!channelEvent) return;
|
||||
addWindow(
|
||||
"open",
|
||||
{ pointer: { id: channelEvent.id } },
|
||||
`Channel ${channelEvent.content || channelEvent.id.slice(0, 8)}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
{/* Show channel context */}
|
||||
{channelEvent && (
|
||||
<div
|
||||
onClick={handleChannelClick}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-accent cursor-pointer transition-colors mb-1"
|
||||
>
|
||||
<Hash className="size-3" />
|
||||
<span>{channelEvent.content || channelEvent.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show quoted message loading state */}
|
||||
{quotedEventId && !parentEvent && (
|
||||
<InlineReplySkeleton icon={<MessageCircle className="size-3" />} />
|
||||
)}
|
||||
|
||||
{/* Show quoted parent message once loaded (only if it's a channel message) */}
|
||||
{quotedEventId && parentEvent && parentEvent.kind === 42 && (
|
||||
<div
|
||||
onClick={handleQuoteClick}
|
||||
className="flex items-start gap-2 p-1 bg-muted/20 text-xs text-muted-foreground hover:bg-muted/30 cursor-crosshair rounded transition-colors mb-1"
|
||||
>
|
||||
<MessageCircle className="size-3 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex items-baseline gap-1 min-w-0 flex-1">
|
||||
<UserName
|
||||
pubkey={parentEvent.pubkey}
|
||||
className="flex-shrink-0 text-accent"
|
||||
/>
|
||||
<div className="truncate line-clamp-1">
|
||||
<RichText
|
||||
event={parentEvent}
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main message content */}
|
||||
<RichText event={event} className="text-sm" depth={depth} />
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
66
src/components/nostr/kinds/ChannelMetadataRenderer.tsx
Normal file
66
src/components/nostr/kinds/ChannelMetadataRenderer.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Settings, Hash } from "lucide-react";
|
||||
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
|
||||
import { UserName } from "../UserName";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { getEventPointerFromETag } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Kind 41 Renderer - Channel Metadata (Feed View)
|
||||
* NIP-28 channel metadata update event
|
||||
*/
|
||||
export function ChannelMetadataRenderer({ event }: BaseEventProps) {
|
||||
// Parse metadata from content
|
||||
let metadata: {
|
||||
name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
relays?: string[];
|
||||
} = {};
|
||||
|
||||
try {
|
||||
metadata = JSON.parse(event.content);
|
||||
} catch {
|
||||
// Invalid JSON, skip metadata parsing
|
||||
}
|
||||
|
||||
// Find the channel event (e-tag points to kind 40)
|
||||
const channelEventPointer = event.tags
|
||||
.filter((t) => t[0] === "e")
|
||||
.map((t) => getEventPointerFromETag(t))[0];
|
||||
|
||||
const channelEvent = useNostrEvent(channelEventPointer);
|
||||
|
||||
const channelName =
|
||||
metadata.name ||
|
||||
channelEvent?.content ||
|
||||
(channelEventPointer && typeof channelEventPointer === "object"
|
||||
? channelEventPointer.id.slice(0, 8)
|
||||
: "Unknown");
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Settings className="size-4" />
|
||||
<span>Updated channel</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Hash className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{channelName}</span>
|
||||
</div>
|
||||
|
||||
{metadata.about && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{metadata.about}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>by</span>
|
||||
<UserName pubkey={event.pubkey} className="text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -148,6 +148,11 @@ import { BadgeAwardRenderer } from "./BadgeAwardRenderer";
|
||||
import { BadgeAwardDetailRenderer } from "./BadgeAwardDetailRenderer";
|
||||
import { ProfileBadgesRenderer } from "./ProfileBadgesRenderer";
|
||||
import { ProfileBadgesDetailRenderer } from "./ProfileBadgesDetailRenderer";
|
||||
import { ChannelCreationRenderer } from "./ChannelCreationRenderer";
|
||||
import { ChannelCreationDetailRenderer } from "./ChannelCreationDetailRenderer";
|
||||
import { ChannelMetadataRenderer } from "./ChannelMetadataRenderer";
|
||||
import { ChannelMessageRenderer } from "./ChannelMessageRenderer";
|
||||
import { ChannelMessageDetailRenderer } from "./ChannelMessageDetailRenderer";
|
||||
|
||||
/**
|
||||
* Registry of kind-specific renderers
|
||||
@@ -167,6 +172,9 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
20: Kind20Renderer, // Picture (NIP-68)
|
||||
21: Kind21Renderer, // Video Event (NIP-71)
|
||||
22: Kind22Renderer, // Short Video (NIP-71)
|
||||
40: ChannelCreationRenderer, // Channel Creation (NIP-28)
|
||||
41: ChannelMetadataRenderer, // Channel Metadata (NIP-28)
|
||||
42: ChannelMessageRenderer, // Channel Message (NIP-28)
|
||||
1063: Kind1063Renderer, // File Metadata (NIP-94)
|
||||
1111: Kind1111Renderer, // Post (NIP-22)
|
||||
1222: VoiceMessageRenderer, // Voice Message (NIP-A0)
|
||||
@@ -275,6 +283,8 @@ const detailRenderers: Record<
|
||||
0: Kind0DetailRenderer, // Profile Metadata Detail
|
||||
3: Kind3DetailView, // Contact List Detail
|
||||
8: BadgeAwardDetailRenderer, // Badge Award Detail (NIP-58)
|
||||
40: ChannelCreationDetailRenderer, // Channel Creation Detail (NIP-28)
|
||||
42: ChannelMessageDetailRenderer, // Channel Message Detail (NIP-28)
|
||||
777: SpellDetailRenderer, // Spell Detail
|
||||
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
|
||||
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
|
||||
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
|
||||
import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
|
||||
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
|
||||
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
|
||||
import { nip19 } from "nostr-tools";
|
||||
// Import other adapters as they're implemented
|
||||
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
|
||||
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
|
||||
|
||||
/**
|
||||
* Parse a chat command identifier and auto-detect the protocol
|
||||
@@ -63,7 +63,7 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
// Try each adapter in priority order
|
||||
const adapters = [
|
||||
// new Nip17Adapter(), // Phase 2
|
||||
// new Nip28Adapter(), // Phase 3
|
||||
new Nip28Adapter(), // Phase 3 - Public channels
|
||||
new Nip29Adapter(), // Phase 4 - Relay groups
|
||||
new Nip53Adapter(), // Phase 5 - Live activity chat
|
||||
// new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now)
|
||||
@@ -84,6 +84,10 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
`Unable to determine chat protocol from identifier: ${identifier}
|
||||
|
||||
Currently supported formats:
|
||||
- note1.../nevent1... (NIP-28 public channel, kind 40)
|
||||
Examples:
|
||||
chat note1xyz...
|
||||
chat nevent1xyz... (with relay hints)
|
||||
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
|
||||
Examples:
|
||||
chat relay.example.com'bitcoin-dev
|
||||
@@ -99,7 +103,6 @@ Currently supported formats:
|
||||
chat naddr1... (group list address)
|
||||
|
||||
More formats coming soon:
|
||||
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
|
||||
- note/nevent (NIP-28 public channels)`,
|
||||
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)`,
|
||||
);
|
||||
}
|
||||
|
||||
114
src/lib/chat/adapters/nip-28-adapter.test.ts
Normal file
114
src/lib/chat/adapters/nip-28-adapter.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Nip28Adapter } from "./nip-28-adapter";
|
||||
|
||||
describe("Nip28Adapter", () => {
|
||||
const adapter = new Nip28Adapter();
|
||||
|
||||
describe("parseIdentifier", () => {
|
||||
it("should parse note1 format (kind 40 event ID)", () => {
|
||||
const eventId =
|
||||
"0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const note = nip19.noteEncode(eventId);
|
||||
|
||||
const result = adapter.parseIdentifier(note);
|
||||
expect(result).toEqual({
|
||||
type: "channel",
|
||||
value: eventId,
|
||||
relays: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse nevent1 format with relay hints", () => {
|
||||
const eventId =
|
||||
"0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
relays: ["wss://relay.example.com", "wss://nos.lol"],
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(nevent);
|
||||
expect(result).toEqual({
|
||||
type: "channel",
|
||||
value: eventId,
|
||||
relays: ["wss://relay.example.com", "wss://nos.lol"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse nevent1 format without relay hints", () => {
|
||||
const eventId =
|
||||
"0000000000000000000000000000000000000000000000000000000000000001";
|
||||
const nevent = nip19.neventEncode({
|
||||
id: eventId,
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(nevent);
|
||||
expect(result).toEqual({
|
||||
type: "channel",
|
||||
value: eventId,
|
||||
relays: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for kind 41 naddr (not yet supported)", () => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 41,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "channel-metadata",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
|
||||
expect(adapter.parseIdentifier(naddr)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-channel identifiers", () => {
|
||||
// NIP-29 group format
|
||||
expect(adapter.parseIdentifier("relay.example.com'group-id")).toBeNull();
|
||||
|
||||
// npub (profile)
|
||||
const npub = nip19.npubEncode(
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
);
|
||||
expect(adapter.parseIdentifier(npub)).toBeNull();
|
||||
|
||||
// naddr kind 30311 (live activity)
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30311,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "live-event",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
expect(adapter.parseIdentifier(naddr)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid formats", () => {
|
||||
expect(adapter.parseIdentifier("")).toBeNull();
|
||||
expect(adapter.parseIdentifier("just-a-string")).toBeNull();
|
||||
expect(adapter.parseIdentifier("note1invaliddata")).toBeNull();
|
||||
expect(adapter.parseIdentifier("nevent1invaliddata")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol properties", () => {
|
||||
it("should have correct protocol and type", () => {
|
||||
expect(adapter.protocol).toBe("nip-28");
|
||||
expect(adapter.type).toBe("channel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCapabilities", () => {
|
||||
it("should return correct capabilities", () => {
|
||||
const capabilities = adapter.getCapabilities();
|
||||
|
||||
expect(capabilities.supportsEncryption).toBe(false);
|
||||
expect(capabilities.supportsThreading).toBe(true);
|
||||
expect(capabilities.supportsModeration).toBe(true);
|
||||
expect(capabilities.supportsRoles).toBe(false);
|
||||
expect(capabilities.supportsGroupManagement).toBe(false);
|
||||
expect(capabilities.canCreateConversations).toBe(true);
|
||||
expect(capabilities.requiresRelay).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
620
src/lib/chat/adapters/nip-28-adapter.ts
Normal file
620
src/lib/chat/adapters/nip-28-adapter.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
import { Observable, firstValueFrom } from "rxjs";
|
||||
import { map, toArray } 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 { publishEvent } from "@/services/hub";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { getNip10References } from "applesauce-common/helpers/threading";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import { mergeRelaySets } from "applesauce-core/helpers";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
|
||||
/**
|
||||
* NIP-28 Adapter - Public Chat Channels
|
||||
*
|
||||
* Features:
|
||||
* - Open participation (anyone can post)
|
||||
* - Multi-relay coordination (no single relay authority)
|
||||
* - Client-side moderation (kinds 43/44)
|
||||
* - Channel messages (kind 42) with NIP-10 threading
|
||||
* - Channel metadata (kind 41) replaceable by creator only
|
||||
*
|
||||
* Channel ID format: note1... or nevent1... (kind 40 event ID)
|
||||
*/
|
||||
export class Nip28Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-28" as const;
|
||||
readonly type = "channel" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts note/nevent (kind 40) or naddr (kind 41)
|
||||
* Examples:
|
||||
* - note1... (kind 40 channel creation event)
|
||||
* - nevent1... (kind 40 with relay hints)
|
||||
* - naddr1... (kind 41 channel metadata address)
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try note format (kind 40 event ID)
|
||||
if (input.startsWith("note1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "note") {
|
||||
return {
|
||||
type: "channel",
|
||||
value: decoded.data,
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not a valid note, fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Try nevent format (kind 40 with relay hints)
|
||||
if (input.startsWith("nevent1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "nevent") {
|
||||
return {
|
||||
type: "channel",
|
||||
value: decoded.data.id,
|
||||
relays: decoded.data.relays || [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not a valid nevent, fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Try naddr format (kind 41 metadata address)
|
||||
if (input.startsWith("naddr1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "naddr" && decoded.data.kind === 41) {
|
||||
// For kind 41, we need to fetch it to get the e-tag pointing to kind 40
|
||||
// For now, return null - we'll support this later
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Not a valid naddr, fall through
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from channel identifier
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
// This adapter only handles channel identifiers
|
||||
if (identifier.type !== "channel") {
|
||||
throw new Error(
|
||||
`NIP-28 adapter cannot handle identifier type: ${identifier.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const channelId = identifier.value;
|
||||
const hintRelays = identifier.relays || [];
|
||||
|
||||
console.log(
|
||||
`[NIP-28] Fetching channel metadata for ${channelId.slice(0, 8)}...`,
|
||||
);
|
||||
|
||||
// Step 1: Fetch the kind 40 creation event
|
||||
const kind40Filter: Filter = {
|
||||
kinds: [40],
|
||||
ids: [channelId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
// Build relay list: hints + user's relay list
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
let relays = [...hintRelays];
|
||||
|
||||
// Add user's outbox relays if available
|
||||
if (activePubkey) {
|
||||
try {
|
||||
const outboxEvent = await firstValueFrom(
|
||||
eventStore.replaceable(10002, activePubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
if (outboxEvent) {
|
||||
const outboxRelays = outboxEvent.tags
|
||||
.filter((t) => t[0] === "r")
|
||||
.map((t) => t[1]);
|
||||
relays = mergeRelaySets(relays, outboxRelays);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors fetching relay list
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default relays if none available
|
||||
if (relays.length === 0) {
|
||||
relays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
}
|
||||
|
||||
const kind40Events: NostrEvent[] = [];
|
||||
const kind40Obs = pool.subscription(relays, [kind40Filter], {
|
||||
eventStore,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log("[NIP-28] Kind 40 fetch timeout");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = kind40Obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
kind40Events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("[NIP-28] Kind 40 fetch error:", err);
|
||||
sub.unsubscribe();
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const kind40Event = kind40Events[0];
|
||||
|
||||
if (!kind40Event) {
|
||||
throw new Error("Channel creation event not found");
|
||||
}
|
||||
|
||||
const creatorPubkey = kind40Event.pubkey;
|
||||
|
||||
// Step 2: Fetch the most recent kind 41 metadata from the creator
|
||||
const kind41Filter: Filter = {
|
||||
kinds: [41],
|
||||
authors: [creatorPubkey],
|
||||
"#e": [channelId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const kind41Events: NostrEvent[] = [];
|
||||
const kind41Obs = pool.subscription(relays, [kind41Filter], {
|
||||
eventStore,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log("[NIP-28] Kind 41 fetch timeout");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = kind41Obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
kind41Events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("[NIP-28] Kind 41 fetch error:", err);
|
||||
sub.unsubscribe();
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Parse metadata from kind 41 (or fall back to kind 40 content)
|
||||
let title: string;
|
||||
let description: string | undefined;
|
||||
let icon: string | undefined;
|
||||
let metadataRelays: string[] = [];
|
||||
|
||||
const metadataEvent = kind41Events[0];
|
||||
|
||||
if (metadataEvent) {
|
||||
// Parse kind 41 content as JSON
|
||||
try {
|
||||
const metadata = JSON.parse(metadataEvent.content);
|
||||
title = metadata.name || kind40Event.content || channelId.slice(0, 8);
|
||||
description = metadata.about;
|
||||
icon = metadata.picture;
|
||||
metadataRelays = metadata.relays || [];
|
||||
} catch {
|
||||
// Fall back to kind 40 content
|
||||
title = kind40Event.content || channelId.slice(0, 8);
|
||||
}
|
||||
} else {
|
||||
// No kind 41, use kind 40 content as title
|
||||
title = kind40Event.content || channelId.slice(0, 8);
|
||||
}
|
||||
|
||||
// Merge relays: hints + metadata relays + user relays
|
||||
const finalRelays = mergeRelaySets(relays, metadataRelays);
|
||||
|
||||
console.log(
|
||||
`[NIP-28] Channel title: ${title}, relays: ${finalRelays.length}`,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `nip-28:${channelId}`,
|
||||
type: "channel",
|
||||
protocol: "nip-28",
|
||||
title,
|
||||
participants: [], // NIP-28 has open participation, no membership list
|
||||
metadata: {
|
||||
channelEvent: kind40Event,
|
||||
description,
|
||||
icon,
|
||||
relayUrl: finalRelays.join(","), // Store as comma-separated for compatibility
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a channel
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const channelEvent = conversation.metadata?.channelEvent;
|
||||
if (!channelEvent) {
|
||||
throw new Error("Channel event not found in conversation metadata");
|
||||
}
|
||||
|
||||
const channelId = channelEvent.id;
|
||||
const relays = conversation.metadata?.relayUrl?.split(",") || [];
|
||||
|
||||
console.log(
|
||||
`[NIP-28] Loading messages for ${channelId.slice(0, 8)}... from ${relays.length} relays`,
|
||||
);
|
||||
|
||||
// Filter for kind 42 messages with root e-tag pointing to channel
|
||||
const filter: Filter = {
|
||||
kinds: [42],
|
||||
"#e": [channelId],
|
||||
limit: options?.limit || 50,
|
||||
};
|
||||
|
||||
if (options?.before) {
|
||||
filter.until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filter.since = options.after;
|
||||
}
|
||||
|
||||
// Clean up any existing subscription for this conversation
|
||||
this.cleanup(conversation.id);
|
||||
|
||||
// Start persistent subscription
|
||||
const subscription = pool
|
||||
.subscription(relays, [filter], {
|
||||
eventStore,
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
console.log("[NIP-28] EOSE received");
|
||||
} else {
|
||||
console.log(
|
||||
`[NIP-28] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Store subscription for cleanup
|
||||
this.subscriptions.set(conversation.id, subscription);
|
||||
|
||||
// Return observable from EventStore
|
||||
return eventStore.timeline(filter).pipe(
|
||||
map((events) => {
|
||||
const messages = events.map((event) =>
|
||||
this.eventToMessage(event, conversation.id, channelId),
|
||||
);
|
||||
|
||||
console.log(`[NIP-28] Timeline has ${messages.length} messages`);
|
||||
// EventStore timeline returns desc, reverse for ascending
|
||||
return messages.reverse();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
conversation: Conversation,
|
||||
before: number,
|
||||
): Promise<Message[]> {
|
||||
const channelEvent = conversation.metadata?.channelEvent;
|
||||
if (!channelEvent) {
|
||||
throw new Error("Channel event not found in conversation metadata");
|
||||
}
|
||||
|
||||
const channelId = channelEvent.id;
|
||||
const relays = conversation.metadata?.relayUrl?.split(",") || [];
|
||||
|
||||
console.log(
|
||||
`[NIP-28] Loading older messages for ${channelId.slice(0, 8)}... before ${before}`,
|
||||
);
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [42],
|
||||
"#e": [channelId],
|
||||
until: before,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
// One-shot request
|
||||
const events = await firstValueFrom(
|
||||
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
console.log(`[NIP-28] Loaded ${events.length} older events`);
|
||||
|
||||
const messages = events.map((event) =>
|
||||
this.eventToMessage(event, conversation.id, channelId),
|
||||
);
|
||||
|
||||
return messages.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the channel
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
if (!activeSigner) {
|
||||
throw new Error("No active signer");
|
||||
}
|
||||
|
||||
const channelEvent = conversation.metadata?.channelEvent;
|
||||
if (!channelEvent) {
|
||||
throw new Error("Channel event not found");
|
||||
}
|
||||
|
||||
const channelId = channelEvent.id;
|
||||
|
||||
// Create event factory
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [];
|
||||
|
||||
// Root e-tag (marked) pointing to channel
|
||||
tags.push(["e", channelId, "", "root"]);
|
||||
|
||||
// Reply e-tag (marked) if replying
|
||||
if (options?.replyTo) {
|
||||
tags.push(["e", options.replyTo, "", "reply"]);
|
||||
|
||||
// Add p-tag for the author of the replied message
|
||||
// Fetch the replied message to get author pubkey
|
||||
try {
|
||||
const repliedEvent = await firstValueFrom(
|
||||
eventStore.event(options.replyTo),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
if (repliedEvent) {
|
||||
tags.push(["p", repliedEvent.pubkey]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore if we can't fetch the replied message
|
||||
}
|
||||
}
|
||||
|
||||
// Add p-tag for channel creator (recommended by NIP-28)
|
||||
tags.push(["p", channelEvent.pubkey]);
|
||||
|
||||
// Add NIP-30 emoji tags
|
||||
if (options?.emojiTags) {
|
||||
for (const emoji of options.emojiTags) {
|
||||
tags.push(["emoji", emoji.shortcode, emoji.url]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add NIP-92 imeta tags for blob attachments
|
||||
if (options?.blobAttachments) {
|
||||
for (const blob of options.blobAttachments) {
|
||||
const imetaParts = [`url ${blob.url}`];
|
||||
if (blob.sha256) imetaParts.push(`x ${blob.sha256}`);
|
||||
if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`);
|
||||
if (blob.size) imetaParts.push(`size ${blob.size}`);
|
||||
tags.push(["imeta", ...imetaParts]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create kind 42 message
|
||||
const draft = await factory.build({ kind: 42, content, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to all channel relays
|
||||
await publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7) to a message in the channel
|
||||
*/
|
||||
async sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): Promise<void> {
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
if (!activeSigner) {
|
||||
throw new Error("No active signer");
|
||||
}
|
||||
|
||||
const channelEvent = conversation.metadata?.channelEvent;
|
||||
if (!channelEvent) {
|
||||
throw new Error("Channel event not found");
|
||||
}
|
||||
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["e", messageId], // Event being reacted to
|
||||
["k", "42"], // Kind of event being reacted to
|
||||
];
|
||||
|
||||
// Add NIP-30 custom emoji tag if provided
|
||||
if (customEmoji) {
|
||||
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
|
||||
}
|
||||
|
||||
const draft = await factory.build({ kind: 7, content: emoji, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
await publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: false, // kind 42 messages are public
|
||||
supportsThreading: true, // NIP-10 marked e-tags
|
||||
supportsModeration: true, // kind 43/44 client-side
|
||||
supportsRoles: false, // No roles in NIP-28
|
||||
supportsGroupManagement: false, // Open participation
|
||||
canCreateConversations: true, // Users can create channels (kind 40)
|
||||
requiresRelay: false, // Multi-relay coordination
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
* First checks EventStore, then fetches from channel relays if needed
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// First check EventStore
|
||||
const cachedEvent = await firstValueFrom(eventStore.event(eventId), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
if (cachedEvent) {
|
||||
return cachedEvent;
|
||||
}
|
||||
|
||||
// Not in store, fetch from channel relays
|
||||
const relays = conversation.metadata?.relayUrl?.split(",") || [];
|
||||
if (relays.length === 0) {
|
||||
console.warn("[NIP-28] No relays available for loading reply message");
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-28] Fetching reply message ${eventId.slice(0, 8)}... from ${relays.length} relays`,
|
||||
);
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription(relays, [filter], { eventStore });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(
|
||||
`[NIP-28] 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(`[NIP-28] Reply message fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert Nostr event to Message
|
||||
*/
|
||||
private eventToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
channelId: string,
|
||||
): Message {
|
||||
// Parse NIP-10 references to find reply target
|
||||
const references = getNip10References(event);
|
||||
let replyTo: string | undefined;
|
||||
|
||||
// Look for reply marker (should point to parent message, not root channel)
|
||||
if (references.reply?.e) {
|
||||
const replyEventId = references.reply.e.id;
|
||||
// Only set replyTo if it's not the channel itself
|
||||
if (replyEventId !== channelId) {
|
||||
replyTo = replyEventId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "nip-28",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { NostrEvent } from "./nostr";
|
||||
*/
|
||||
export const CHAT_KINDS = [
|
||||
9, // NIP-29: Group chat messages
|
||||
42, // NIP-28: Channel messages
|
||||
9321, // NIP-61: Nutzaps (ecash zaps in groups/live chats)
|
||||
1311, // NIP-53: Live chat messages
|
||||
9735, // NIP-57: Zap receipts (part of chat context)
|
||||
|
||||
@@ -476,15 +476,17 @@ 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, NIP-53 live activity chat, and multi-room group list interface. 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. For multi-room interface, pass the naddr of a kind 10009 group list event.",
|
||||
"Join and participate in Nostr chat conversations. Supports NIP-28 public channels, NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-28 channels, use note1... or nevent1... (kind 40 creation event). 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. For multi-room interface, pass the naddr of a kind 10009 group list event.",
|
||||
options: [
|
||||
{
|
||||
flag: "<identifier>",
|
||||
description:
|
||||
"NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)",
|
||||
"NIP-28 channel (note1.../nevent1... kind 40), NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"chat note1... Open NIP-28 public channel",
|
||||
"chat nevent1... Open NIP-28 channel with relay hints",
|
||||
"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...30311... Join NIP-53 live activity chat",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.3"}
|
||||
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user