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 && (
+
+

+
+ )}
+
+ {/* 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