feat: implement NIP-28 public channel support

Add full support for NIP-28 public channels with chat functionality,
client-side moderation, and rich event rendering.

**Features:**
- NIP-28 adapter with identifier parsing (note1/nevent1)
- Channel resolution (kind 40 creation + kind 41 metadata)
- Message loading and sending (kind 42 with NIP-10 threading)
- Client-side hide (kind 43) and mute (kind 44) actions
- Rich renderers for all NIP-28 event kinds (40-44)
- Auto-detect protocol in chat parser
- Relay hints from channel metadata

**Changes:**
- Created `src/lib/chat/adapters/nip-28-adapter.ts`
- Created channel renderers (ChannelMetadata, ChannelMessage, ChannelHide, ChannelMute)
- Registered renderers for kinds 40-44 in renderer registry
- Updated chat parser to support note1/nevent1 identifiers
- Updated ChatViewer to instantiate NIP-28 adapter
- Updated CLAUDE.md documentation
- Added tests for NIP-28 parsing

**Usage:**
```bash
chat note1abc...     # Join channel by creation event
chat nevent1...      # Join with relay hints
```

All tests pass (840/840).
This commit is contained in:
Claude
2026-01-13 18:39:29 +00:00
parent 4078ea372a
commit 5260359385
10 changed files with 933 additions and 9 deletions

View File

@@ -188,12 +188,20 @@ const text = getHighlightText(event);
## Chat System
**Current Status**: Only NIP-29 (relay-based groups) is supported. Other protocols are planned for future releases.
**Current Status**: NIP-28 (public channels) and NIP-29 (relay-based groups) are supported. NIP-53 (live activity chat) is also supported. Other protocols (NIP-C7, NIP-17) are planned for future releases.
**Architecture**: Protocol adapter pattern for supporting multiple Nostr messaging protocols:
- `src/lib/chat/adapters/base-adapter.ts` - Base interface all adapters implement
- `src/lib/chat/adapters/nip-28-adapter.ts` - NIP-28 public channels (currently enabled)
- `src/lib/chat/adapters/nip-29-adapter.ts` - NIP-29 relay groups (currently enabled)
- Other adapters (NIP-C7, NIP-17, NIP-28, NIP-53) are implemented but commented out
- `src/lib/chat/adapters/nip-53-adapter.ts` - NIP-53 live activity chat (currently enabled)
- Other adapters (NIP-C7, NIP-17) are planned but not yet implemented
**NIP-28 Channel Format**: `note1.../nevent1...` (kind 40 channel creation event)
- Examples: `chat note1abc...`, `chat nevent1...` (with relay hints)
- Open public channels - anyone can post messages
- Client-side moderation (hide messages with kind 43, mute users with kind 44)
- Messages are kind 42, channel metadata is kind 40/41
**NIP-29 Group Format**: `relay'group-id` (wss:// prefix optional)
- Examples: `relay.example.com'bitcoin-dev`, `wss://nos.lol'welcome`
@@ -208,6 +216,7 @@ const text = getHighlightText(event);
**Usage**:
```bash
chat note1abc... # Join NIP-28 public channel
chat relay.example.com'bitcoin-dev # Join NIP-29 group
chat wss://nos.lol'welcome # Join with explicit wss:// prefix
```

View File

@@ -21,6 +21,7 @@ import type {
LiveActivityMetadata,
} from "@/types/chat";
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
import { Nip28Adapter } from "@/lib/chat/adapters/nip-28-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
@@ -947,12 +948,12 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
// return new NipC7Adapter();
case "nip-28":
return new Nip28Adapter();
case "nip-29":
return new Nip29Adapter();
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
// return new Nip17Adapter();
// case "nip-28": // Phase 3 - Public channels (coming soon)
// return new Nip28Adapter();
case "nip-53":
return new Nip53Adapter();
default:

View File

@@ -0,0 +1,46 @@
import { EyeOff } from "lucide-react";
import type { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
/**
* Renderer for NIP-28 Hide Message (kind 43)
*/
export function ChannelHideRenderer({ event, depth }: BaseEventProps) {
// Get the message being hidden (e tag)
const eTag = event.tags.find((t) => t[0] === "e");
const hiddenMessageId = eTag?.[1];
// Parse reason from content (optional JSON)
let reason: string | undefined;
try {
const parsed = JSON.parse(event.content);
reason = parsed.reason;
} catch {
// Not JSON or no reason
}
return (
<BaseEventContainer event={event}>
<div className="space-y-2">
<div className="flex items-start gap-2">
<EyeOff className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm text-muted-foreground">
Hid channel message
</span>
{hiddenMessageId && (
<div className="text-xs text-muted-foreground font-mono mt-1">
{hiddenMessageId.slice(0, 8)}...
</div>
)}
{reason && (
<div className="text-xs text-muted-foreground mt-1">
Reason: {reason}
</div>
)}
</div>
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,33 @@
import { MessageSquare } from "lucide-react";
import type { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { RichText } from "../RichText";
/**
* Renderer for NIP-28 Channel Messages (kind 42)
*/
export function ChannelMessageRenderer({ event, depth }: BaseEventProps) {
// Get channel root (first "e" tag marked as root or reply)
const eTags = event.tags.filter((t) => t[0] === "e");
const rootTag = eTags.find((t) => t[3] === "root");
const replyTag = eTags.find((t) => t[3] === "reply");
const isReply = !!replyTag;
return (
<BaseEventContainer event={event}>
<div className="space-y-2">
{isReply && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MessageSquare className="w-3 h-3" />
<span>Reply in channel</span>
</div>
)}
<div className="text-sm">
<RichText content={event.content} event={event} />
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,69 @@
import { Hash, Settings } from "lucide-react";
import type { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { getTagValues } from "@/lib/nostr-utils";
import { nip19 } from "nostr-tools";
/**
* Renderer for NIP-28 Channel Creation (kind 40) and Metadata (kind 41)
*/
export function ChannelMetadataRenderer({ event, depth }: BaseEventProps) {
// Parse metadata from JSON content
let metadata: any = {};
try {
metadata = JSON.parse(event.content);
} catch {
// Invalid JSON, show as-is
}
const name = metadata.name || "Unnamed Channel";
const about = metadata.about;
const picture = metadata.picture;
const relays = metadata.relays || [];
const isCreation = event.kind === 40;
const Icon = isCreation ? Hash : Settings;
const action = isCreation ? "created channel" : "updated channel";
// Generate nevent for easy sharing
const nevent = nip19.neventEncode({
id: event.id,
relays: relays.length > 0 ? relays : undefined,
});
return (
<BaseEventContainer event={event}>
<div className="space-y-2">
<div className="flex items-start gap-2">
<Icon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm text-muted-foreground">{action}: </span>
<span className="font-medium">{name}</span>
</div>
</div>
{picture && (
<img
src={picture}
alt={name}
className="w-16 h-16 rounded object-cover"
/>
)}
{about && (
<p className="text-sm text-muted-foreground line-clamp-2">{about}</p>
)}
{relays.length > 0 && (
<div className="text-xs text-muted-foreground">
Relays: {relays.length}
</div>
)}
<div className="text-xs text-muted-foreground font-mono">
Join: chat {nevent.slice(0, 20)}...
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,41 @@
import { Eye } from "lucide-react";
import type { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { UserName } from "../UserName";
/**
* Renderer for NIP-28 Mute User (kind 44)
*/
export function ChannelMuteRenderer({ event, depth }: BaseEventProps) {
// Get the user being muted (p tag)
const pTag = event.tags.find((t) => t[0] === "p");
const mutedPubkey = pTag?.[1];
// Parse reason from content (optional JSON)
let reason: string | undefined;
try {
const parsed = JSON.parse(event.content);
reason = parsed.reason;
} catch {
// Not JSON or no reason
}
return (
<BaseEventContainer event={event}>
<div className="space-y-2">
<div className="flex items-start gap-2">
<Eye className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm text-muted-foreground">Muted user: </span>
{mutedPubkey && <UserName pubkey={mutedPubkey} />}
{reason && (
<div className="text-xs text-muted-foreground mt-1">
Reason: {reason}
</div>
)}
</div>
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -8,6 +8,10 @@ import { RepostRenderer } from "./RepostRenderer";
import { Kind7Renderer } from "./ReactionRenderer";
import { Kind9Renderer } from "./ChatMessageRenderer";
import { LiveChatMessageRenderer } from "./LiveChatMessageRenderer";
import { ChannelMetadataRenderer } from "./ChannelMetadataRenderer";
import { ChannelMessageRenderer } from "./ChannelMessageRenderer";
import { ChannelHideRenderer } from "./ChannelHideRenderer";
import { ChannelMuteRenderer } from "./ChannelMuteRenderer";
import { Kind20Renderer } from "./PictureRenderer";
import { Kind21Renderer } from "./VideoRenderer";
import { Kind22Renderer } from "./ShortVideoRenderer";
@@ -148,6 +152,11 @@ 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: ChannelMetadataRenderer, // Channel Creation (NIP-28)
41: ChannelMetadataRenderer, // Channel Metadata (NIP-28)
42: ChannelMessageRenderer, // Channel Message (NIP-28)
43: ChannelHideRenderer, // Channel Hide Message (NIP-28)
44: ChannelMuteRenderer, // Channel Mute User (NIP-28)
1063: Kind1063Renderer, // File Metadata (NIP-94)
1111: Kind1111Renderer, // Post (NIP-22)
1222: VoiceMessageRenderer, // Voice Message (NIP-A0)

View File

@@ -76,6 +76,45 @@ describe("parseChatCommand", () => {
});
});
describe("NIP-28 public channels", () => {
it("should parse note1 identifier (kind 40 channel)", () => {
// Create a valid note1 encoding for testing
const eventId =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
const note = nip19.noteEncode(eventId);
const result = parseChatCommand([note]);
expect(result.protocol).toBe("nip-28");
expect(result.identifier).toEqual({
type: "channel",
value: eventId,
relays: [],
});
expect(result.adapter.protocol).toBe("nip-28");
});
it("should parse nevent1 identifier with relay hints", () => {
// Create a valid nevent1 encoding with relay hints
const eventId =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
const nevent = nip19.neventEncode({
id: eventId,
relays: ["wss://relay.example.com", "wss://nos.lol"],
});
const result = parseChatCommand([nevent]);
expect(result.protocol).toBe("nip-28");
expect(result.identifier).toEqual({
type: "channel",
value: eventId,
relays: ["wss://relay.example.com", "wss://nos.lol"],
});
expect(result.adapter.protocol).toBe("nip-28");
});
});
describe("error handling", () => {
it("should throw error when no identifier provided", () => {
expect(() => parseChatCommand([])).toThrow(
@@ -95,7 +134,7 @@ describe("parseChatCommand", () => {
);
});
it("should throw error for note/nevent (NIP-28 not implemented)", () => {
it("should throw error for malformed note", () => {
expect(() => parseChatCommand(["note1xyz"])).toThrow(
/Unable to determine chat protocol/,
);

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 event)
Examples:
chat note1abc...
chat nevent1... (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,674 @@
import { Observable, firstValueFrom } from "rxjs";
import { map, first, 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 type { ChatAction, GetActionsOptions } from "@/types/chat-actions";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { publishEvent } from "@/services/hub";
import accountManager from "@/services/accounts";
import { getTagValues } from "@/lib/nostr-utils";
import { EventFactory } from "applesauce-core/event-factory";
/**
* NIP-28 Adapter - Public Chat Channels
*
* Features:
* - Open public channels (no membership required)
* - Client-side moderation (hide/mute)
* - Channel metadata (name, about, picture, relays)
* - Messages with NIP-10 threading
*
* Channel ID format: note1.../nevent1... (kind 40 channel creation event)
* Events use "e" tag to reference the channel (root tag)
*/
export class Nip28Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-28" as const;
readonly type = "channel" as const;
/**
* Parse identifier - accepts note/nevent pointing to kind 40
* Examples:
* - note1abcdef... (kind 40 channel creation event)
* - nevent1... (kind 40 with relay hints)
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Try note format (just event ID)
if (input.startsWith("note1")) {
try {
const decoded = nip19.decode(input);
if (decoded.type === "note") {
return {
type: "channel",
value: decoded.data,
relays: [],
};
}
} catch {
return null;
}
}
// Try nevent format (event ID 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 {
return null;
}
}
return null;
}
/**
* Resolve conversation from channel identifier
* Fetches kind 40 (creation) and kind 41 (latest metadata)
*/
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 relayHints = identifier.relays || [];
console.log(
`[NIP-28] Fetching channel ${channelId.slice(0, 8)}... from relays`,
);
// First, fetch the kind 40 channel creation event
const creationFilter: Filter = {
kinds: [40],
ids: [channelId],
limit: 1,
};
// Try relay hints first, then fall back to pool
const relaysToQuery = relayHints.length > 0 ? relayHints : pool.urls$.value;
const creationEvents: NostrEvent[] = [];
const creationObs = pool.subscription(relaysToQuery, [creationFilter], {
eventStore,
});
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.log("[NIP-28] Channel creation fetch timeout");
resolve();
}, 5000);
const sub = creationObs.subscribe({
next: (response) => {
if (typeof response === "string") {
// EOSE received
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
// Event received
creationEvents.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error("[NIP-28] Channel creation fetch error:", err);
sub.unsubscribe();
resolve();
},
});
});
const creationEvent = creationEvents[0];
if (!creationEvent) {
throw new Error(
`Channel creation event not found: ${channelId.slice(0, 8)}...`,
);
}
console.log(
`[NIP-28] Found channel creation event by ${creationEvent.pubkey}`,
);
// Parse metadata from kind 40 content (JSON)
let metadata: any = {};
try {
metadata = JSON.parse(creationEvent.content);
} catch (e) {
console.warn("[NIP-28] Failed to parse channel metadata:", e);
}
// Now fetch latest kind 41 metadata update (if any)
// Kind 41 events should be from the same pubkey as kind 40
const metadataFilter: Filter = {
kinds: [41],
"#e": [channelId],
authors: [creationEvent.pubkey],
limit: 1,
};
const metadataEvents: NostrEvent[] = [];
const metadataObs = pool.subscription(relaysToQuery, [metadataFilter], {
eventStore,
});
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.log("[NIP-28] Metadata update fetch timeout");
resolve();
}, 3000);
const sub = metadataObs.subscribe({
next: (response) => {
if (typeof response === "string") {
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
metadataEvents.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error("[NIP-28] Metadata update fetch error:", err);
sub.unsubscribe();
resolve();
},
});
});
// If kind 41 exists, use it to override kind 40 metadata
const metadataUpdate = metadataEvents[0];
if (metadataUpdate) {
try {
const updatedMetadata = JSON.parse(metadataUpdate.content);
metadata = { ...metadata, ...updatedMetadata };
console.log("[NIP-28] Applied metadata update from kind 41");
} catch (e) {
console.warn("[NIP-28] Failed to parse metadata update:", e);
}
}
// Extract channel info
const title = metadata.name || `Channel ${channelId.slice(0, 8)}...`;
const description = metadata.about;
const icon = metadata.picture;
const relays = metadata.relays || [];
console.log(`[NIP-28] Channel title: ${title}`);
return {
id: `nip-28:${channelId}`,
type: "channel",
protocol: "nip-28",
title,
participants: [], // NIP-28 channels don't track participants
metadata: {
channelEvent: creationEvent,
...(description && { description }),
...(icon && { icon }),
...(relays.length > 0 && { relayUrl: relays[0] }), // Store first relay
},
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 required");
}
const channelId = channelEvent.id;
console.log(
`[NIP-28] Loading messages for channel ${channelId.slice(0, 8)}...`,
);
// Determine relays to query
let relays: string[] = [];
// Try metadata relays first
const channelMetadata = channelEvent.content
? (() => {
try {
return JSON.parse(channelEvent.content);
} catch {
return {};
}
})()
: {};
if (channelMetadata.relays && Array.isArray(channelMetadata.relays)) {
relays = channelMetadata.relays;
}
// Fall back to pool if no relay hints
if (relays.length === 0) {
relays = pool.urls$.value;
}
console.log(`[NIP-28] Querying ${relays.length} relays for messages`);
// Filter for kind 42 messages referencing this channel
// Messages should have an "e" tag (root) pointing to the 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
const conversationId = `nip-28:${channelId}`;
this.cleanup(conversationId);
// Start a 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 message: ${response.id.slice(0, 8)}...`,
);
}
},
});
// Store subscription for cleanup
this.subscriptions.set(conversationId, subscription);
// Return observable from EventStore which will update automatically
return eventStore.timeline(filter).pipe(
map((events) => {
const messages = events.map((event) =>
this.eventToMessage(event, conversation.id),
);
console.log(`[NIP-28] Timeline has ${messages.length} messages`);
// EventStore returns events in desc order, reverse for chat
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 required");
}
const channelId = channelEvent.id;
console.log(
`[NIP-28] Loading older messages for ${channelId.slice(0, 8)}... before ${before}`,
);
// Determine relays
let relays: string[] = [];
const channelMetadata = (() => {
try {
return JSON.parse(channelEvent.content);
} catch {
return {};
}
})();
if (channelMetadata.relays && Array.isArray(channelMetadata.relays)) {
relays = channelMetadata.relays;
} else {
relays = pool.urls$.value;
}
const filter: Filter = {
kinds: [42],
"#e": [channelId],
until: before,
limit: 50,
};
const events = await firstValueFrom(
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
);
console.log(`[NIP-28] Loaded ${events.length} older messages`);
const messages = events.map((event) =>
this.eventToMessage(event, conversation.id),
);
return messages.reverse();
}
/**
* Send a message to the channel
*/
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 channelEvent = conversation.metadata?.channelEvent;
if (!channelEvent) {
throw new Error("Channel event required");
}
const channelId = channelEvent.id;
// Create event factory and sign event
const factory = new EventFactory();
factory.setSigner(activeSigner);
// Build tags according to NIP-28 and NIP-10
const tags: string[][] = [];
// Root reference to channel (NIP-10)
tags.push(["e", channelId, "", "root"]);
// If replying to a message, add reply reference
if (options?.replyTo) {
tags.push(["e", options.replyTo, "", "reply"]);
// Also add p-tag for the author of the replied-to message
// We need to fetch the replied-to event to get its author
const repliedEvent = await this.loadReplyMessage(
conversation,
options.replyTo,
);
if (repliedEvent) {
tags.push(["p", repliedEvent.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]);
}
}
// Use kind 42 for channel messages
const draft = await factory.build({ kind: 42, content, tags });
const event = await factory.sign(draft);
// Publish to channel relays (or user's relays if no channel relays)
await publishEvent(event);
}
/**
* Get protocol capabilities
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: false, // kind 42 messages are public
supportsThreading: true, // NIP-10 e-tag replies
supportsModeration: true, // Client-side hide/mute
supportsRoles: false, // No roles in NIP-28
supportsGroupManagement: false, // No join/leave
canCreateConversations: false, // Channels created separately
requiresRelay: false, // Can use any relays
};
}
/**
* Get available actions for NIP-28 channels
* Returns hide and mute actions
*/
getActions(options?: GetActionsOptions): ChatAction[] {
return [
{
name: "hide",
description: "Hide a message (client-side)",
handler: async (context) => {
// Kind 43 - hide message
// This would need additional context (which message to hide)
// For now, return a placeholder
return {
success: false,
message:
"Hide action requires message context. Use the message context menu.",
};
},
},
{
name: "mute",
description: "Mute a user (client-side)",
handler: async (context) => {
// Kind 44 - mute user
// This would need additional context (which user to mute)
// For now, return a placeholder
return {
success: false,
message:
"Mute action requires user context. Use the user context menu.",
};
},
},
];
}
/**
* Hide a message (kind 43)
* Creates a client-side hide event
*/
async hideMessage(messageId: string, reason?: string): 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 factory = new EventFactory();
factory.setSigner(activeSigner);
const tags: string[][] = [["e", messageId]];
const content = reason ? JSON.stringify({ reason }) : "";
const draft = await factory.build({ kind: 43, content, tags });
const event = await factory.sign(draft);
await publishEvent(event);
}
/**
* Mute a user (kind 44)
* Creates a client-side mute event
*/
async muteUser(pubkey: string, reason?: string): 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 factory = new EventFactory();
factory.setSigner(activeSigner);
const tags: string[][] = [["p", pubkey]];
const content = reason ? JSON.stringify({ reason }) : "";
const draft = await factory.build({ kind: 44, content, tags });
const event = await factory.sign(draft);
await publishEvent(event);
}
/**
* 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 eventStore
.event(eventId)
.pipe(first())
.toPromise();
if (cachedEvent) {
return cachedEvent;
}
// Not in store, fetch from channel relays
const channelEvent = conversation.metadata?.channelEvent;
if (!channelEvent) {
console.warn("[NIP-28] No channel event for loading reply message");
return null;
}
// Determine relays
let relays: string[] = [];
const channelMetadata = (() => {
try {
return JSON.parse(channelEvent.content);
} catch {
return {};
}
})();
if (channelMetadata.relays && Array.isArray(channelMetadata.relays)) {
relays = channelMetadata.relays;
} else {
relays = pool.urls$.value;
}
console.log(
`[NIP-28] Fetching reply message ${eventId.slice(0, 8)}... from 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): Message {
// Look for reply e-tags (NIP-10)
// Root is the channel, reply is the message being replied to
const eTags = event.tags.filter((t) => t[0] === "e");
const replyTag = eTags.find((t) => t[3] === "reply");
const replyTo = replyTag?.[1];
return {
id: event.id,
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo,
protocol: "nip-28",
metadata: {
encrypted: false, // kind 42 messages are always public
},
event,
};
}
}