diff --git a/src/components/nostr/kinds/ChannelCreationDetailRenderer.tsx b/src/components/nostr/kinds/ChannelCreationDetailRenderer.tsx new file mode 100644 index 0000000..5fd4823 --- /dev/null +++ b/src/components/nostr/kinds/ChannelCreationDetailRenderer.tsx @@ -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 ( +
+ {/* Header Image */} + {picture && ( +
+ {title} +
+ )} + + {/* Channel Info Section */} +
+ {/* Title */} +
+ +

{title}

+
+ + {/* Creator */} +
+ + Created by + +
+ + {/* Created Date */} +
+ + +
+ + {/* Description */} + {description && ( +
+ +

+ {description} +

+
+ )} + + {/* Relays */} + {allRelays.length > 0 && ( +
+ +
+ {allRelays.map((relay) => ( +
+ + {relay} +
+ ))} +
+
+ )} + + {/* Open Channel Button */} + + + {/* Metadata Status */} + {metadataEvent && ( +
+ Last updated +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/ChannelCreationRenderer.tsx b/src/components/nostr/kinds/ChannelCreationRenderer.tsx new file mode 100644 index 0000000..c8d9dcd --- /dev/null +++ b/src/components/nostr/kinds/ChannelCreationRenderer.tsx @@ -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 ( + +
+
+ + {channelName} +
+ +
+ + Created by + +
+ + +
+
+ ); +} diff --git a/src/components/nostr/kinds/ChannelMessageDetailRenderer.tsx b/src/components/nostr/kinds/ChannelMessageDetailRenderer.tsx new file mode 100644 index 0000000..d2055b1 --- /dev/null +++ b/src/components/nostr/kinds/ChannelMessageDetailRenderer.tsx @@ -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 ( +
+ {/* Channel Context */} + {channelEvent && ( +
+ +
+
+ + + {channelEvent.content || channelEvent.id.slice(0, 8)} + +
+ +
+
+ )} + + {/* Parent Message (if reply) */} + {parentMessage && ( +
+ +
+
+ + + + +
+
+ +
+ +
+
+ )} + + {/* Message Author */} +
+ +
+ + +
+ + +
+
+
+ + {/* Message Content */} +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/ChannelMessageRenderer.tsx b/src/components/nostr/kinds/ChannelMessageRenderer.tsx new file mode 100644 index 0000000..e3ba71d --- /dev/null +++ b/src/components/nostr/kinds/ChannelMessageRenderer.tsx @@ -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 ( + + {/* Show channel context */} + {channelEvent && ( +
+ + {channelEvent.content || channelEvent.id.slice(0, 8)} +
+ )} + + {/* Show quoted message loading state */} + {quotedEventId && !parentEvent && ( + } /> + )} + + {/* Show quoted parent message once loaded (only if it's a channel message) */} + {quotedEventId && parentEvent && parentEvent.kind === 42 && ( +
+ +
+ +
+ +
+
+
+ )} + + {/* Main message content */} + +
+ ); +} diff --git a/src/components/nostr/kinds/ChannelMetadataRenderer.tsx b/src/components/nostr/kinds/ChannelMetadataRenderer.tsx new file mode 100644 index 0000000..54faf5f --- /dev/null +++ b/src/components/nostr/kinds/ChannelMetadataRenderer.tsx @@ -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 ( + +
+
+ + Updated channel +
+ +
+ + {channelName} +
+ + {metadata.about && ( +
+ {metadata.about} +
+ )} + +
+ by + +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 1a8396e..935f4e4 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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) diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 8a0778f..00e9340 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -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)`, ); } diff --git a/src/lib/chat/adapters/nip-28-adapter.test.ts b/src/lib/chat/adapters/nip-28-adapter.test.ts new file mode 100644 index 0000000..b851f09 --- /dev/null +++ b/src/lib/chat/adapters/nip-28-adapter.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/chat/adapters/nip-28-adapter.ts b/src/lib/chat/adapters/nip-28-adapter.ts new file mode 100644 index 0000000..a5b0785 --- /dev/null +++ b/src/lib/chat/adapters/nip-28-adapter.ts @@ -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 { + // 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((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((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 { + 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 { + 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 { + 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 { + 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 { + // 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((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, + }; + } +} diff --git a/src/types/chat.ts b/src/types/chat.ts index f81b518..b494024 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -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) diff --git a/src/types/man.ts b/src/types/man.ts index c566eed..758f5cd 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -476,15 +476,17 @@ export const manPages: Record = { section: "1", synopsis: "chat ", 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: "", 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", diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo index 75ea001..5e39d3d 100644 --- a/tsconfig.node.tsbuildinfo +++ b/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file