mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
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:
13
CLAUDE.md
13
CLAUDE.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
46
src/components/nostr/kinds/ChannelHideRenderer.tsx
Normal file
46
src/components/nostr/kinds/ChannelHideRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/nostr/kinds/ChannelMessageRenderer.tsx
Normal file
33
src/components/nostr/kinds/ChannelMessageRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/nostr/kinds/ChannelMetadataRenderer.tsx
Normal file
69
src/components/nostr/kinds/ChannelMetadataRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/nostr/kinds/ChannelMuteRenderer.tsx
Normal file
41
src/components/nostr/kinds/ChannelMuteRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
674
src/lib/chat/adapters/nip-28-adapter.ts
Normal file
674
src/lib/chat/adapters/nip-28-adapter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user