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:
Claude
2026-01-18 07:53:09 +00:00
parent c7cced2a9e
commit 42694be80d
12 changed files with 1270 additions and 7 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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)

View File

@@ -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)`,
);
}

View 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);
});
});
});

View 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,
};
}
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.3"}
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}