diff --git a/src/components/nostr/kinds/BookmarkListRenderer.tsx b/src/components/nostr/kinds/BookmarkListRenderer.tsx
new file mode 100644
index 0000000..145bf87
--- /dev/null
+++ b/src/components/nostr/kinds/BookmarkListRenderer.tsx
@@ -0,0 +1,137 @@
+import { Bookmark, FileText, Link } from "lucide-react";
+import { getTagValues } from "@/lib/nostr-utils";
+import {
+ getEventPointerFromETag,
+ getAddressPointerFromATag,
+} from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { EventRefListFull, UrlListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract event pointers from e tags
+ */
+function getEventPointers(event: NostrEvent): EventPointer[] {
+ const pointers: EventPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "e" && tag[1]) {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Extract address pointers from a tags
+ */
+function getAddressPointers(event: NostrEvent): AddressPointer[] {
+ const pointers: AddressPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "a" && tag[1]) {
+ const pointer = getAddressPointerFromATag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 10003 Renderer - Bookmark List (Feed View)
+ * NIP-51 list of bookmarked events, addresses, and URLs
+ */
+export function BookmarkListRenderer({ event }: BaseEventProps) {
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+ const urls = getTagValues(event, "r");
+
+ const totalItems =
+ eventPointers.length + addressPointers.length + urls.length;
+
+ if (totalItems === 0) {
+ return (
+
+
+ Empty bookmark list
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Bookmarks
+
+
+
+ {(eventPointers.length > 0 || addressPointers.length > 0) && (
+
+
+
+ {eventPointers.length + addressPointers.length} events
+
+
+ )}
+ {urls.length > 0 && (
+
+
+ {urls.length} links
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * Kind 10003 Detail View - Full bookmark list
+ */
+export function BookmarkListDetailRenderer({ event }: { event: NostrEvent }) {
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+ const urls = getTagValues(event, "r");
+
+ return (
+
+
+
+ Bookmarks
+
+
+ {(eventPointers.length > 0 || addressPointers.length > 0) && (
+
}
+ />
+ )}
+
+ {urls.length > 0 &&
}
+
+ {eventPointers.length === 0 &&
+ addressPointers.length === 0 &&
+ urls.length === 0 && (
+
+ Empty bookmark list
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/BookmarkSetRenderer.tsx b/src/components/nostr/kinds/BookmarkSetRenderer.tsx
new file mode 100644
index 0000000..e596fa0
--- /dev/null
+++ b/src/components/nostr/kinds/BookmarkSetRenderer.tsx
@@ -0,0 +1,153 @@
+import { Bookmark, FileText, Link } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import { getTagValues } from "@/lib/nostr-utils";
+import {
+ getEventPointerFromETag,
+ getAddressPointerFromATag,
+} from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { EventRefListFull, UrlListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract event pointers from e tags
+ */
+function getEventPointers(event: NostrEvent): EventPointer[] {
+ const pointers: EventPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "e" && tag[1]) {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Extract address pointers from a tags
+ */
+function getAddressPointers(event: NostrEvent): AddressPointer[] {
+ const pointers: AddressPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "a" && tag[1]) {
+ const pointer = getAddressPointerFromATag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 30003 Renderer - Bookmark Set (Feed View)
+ * NIP-51 parameterized list of bookmarks
+ * Each set has a unique identifier (d tag) like "read-later", "favorites", etc.
+ */
+export function BookmarkSetRenderer({ event }: BaseEventProps) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+ const urls = getTagValues(event, "r");
+
+ const totalItems =
+ eventPointers.length + addressPointers.length + urls.length;
+
+ return (
+
+
+
+
+ {title}
+
+
+ {totalItems === 0 ? (
+
+ Empty bookmark set
+
+ ) : (
+
+ {(eventPointers.length > 0 || addressPointers.length > 0) && (
+
+
+
+ {eventPointers.length + addressPointers.length} events
+
+
+ )}
+ {urls.length > 0 && (
+
+
+ {urls.length} links
+
+ )}
+
+ )}
+
+
+ );
+}
+
+/**
+ * Kind 30003 Detail View - Full bookmark set
+ */
+export function BookmarkSetDetailRenderer({ event }: { event: NostrEvent }) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const description = getTagValue(event, "description");
+ const image = getTagValue(event, "image");
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+ const urls = getTagValues(event, "r");
+
+ return (
+
+
+ {image && (
+

+ )}
+
+
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+
+ {(eventPointers.length > 0 || addressPointers.length > 0) && (
+
}
+ />
+ )}
+
+ {urls.length > 0 &&
}
+
+ {eventPointers.length === 0 &&
+ addressPointers.length === 0 &&
+ urls.length === 0 && (
+
+ Empty bookmark set
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/ChannelListRenderer.tsx b/src/components/nostr/kinds/ChannelListRenderer.tsx
new file mode 100644
index 0000000..b8a8e37
--- /dev/null
+++ b/src/components/nostr/kinds/ChannelListRenderer.tsx
@@ -0,0 +1,89 @@
+import { MessageCircle, Hash } from "lucide-react";
+import { getEventPointerFromETag } from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { EventRefListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract event pointers from e tags (for channel references)
+ * Channels are kind 40 events
+ */
+function getChannelPointers(event: NostrEvent): EventPointer[] {
+ const pointers: EventPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "e" && tag[1]) {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 10005 Renderer - Public Chats List (Feed View)
+ * NIP-51 list of public chat channels (kind 40)
+ * Note: This is different from kind 10009 which is for NIP-29 groups
+ */
+export function ChannelListRenderer({ event }: BaseEventProps) {
+ const channels = getChannelPointers(event);
+
+ if (channels.length === 0) {
+ return (
+
+ No channels
+
+ );
+ }
+
+ return (
+
+
+
+
+ Public Channels
+
+
+
+
+ {channels.length} channels
+
+
+
+ );
+}
+
+/**
+ * Kind 10005 Detail View - Full channel list
+ */
+export function ChannelListDetailRenderer({ event }: { event: NostrEvent }) {
+ const channels = getChannelPointers(event);
+
+ return (
+
+
+
+ Public Channels
+
+
+ {channels.length > 0 ? (
+
}
+ />
+ ) : (
+
No channels
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/CommunityListRenderer.tsx b/src/components/nostr/kinds/CommunityListRenderer.tsx
new file mode 100644
index 0000000..a6ebe01
--- /dev/null
+++ b/src/components/nostr/kinds/CommunityListRenderer.tsx
@@ -0,0 +1,93 @@
+import { Users2, Globe } from "lucide-react";
+import { getAddressPointerFromATag } from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { EventRefListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { AddressPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract address pointers from a tags (for community references)
+ * Communities are kind 34550 events
+ */
+function getCommunityPointers(event: NostrEvent): AddressPointer[] {
+ const pointers: AddressPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "a" && tag[1]) {
+ const pointer = getAddressPointerFromATag(tag);
+ // Only include kind 34550 (community definitions)
+ if (pointer && pointer.kind === 34550) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 10004 Renderer - Community List (Feed View)
+ * NIP-51 list of communities the user is part of
+ */
+export function CommunityListRenderer({ event }: BaseEventProps) {
+ const communities = getCommunityPointers(event);
+
+ if (communities.length === 0) {
+ return (
+
+
+ No communities
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Communities
+
+
+
+
+ {communities.length} communities
+
+
+
+ );
+}
+
+/**
+ * Kind 10004 Detail View - Full community list
+ */
+export function CommunityListDetailRenderer({ event }: { event: NostrEvent }) {
+ const communities = getCommunityPointers(event);
+
+ return (
+
+
+
+ Communities
+
+
+ {communities.length > 0 ? (
+
}
+ />
+ ) : (
+
+ No communities
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/CurationSetRenderer.tsx b/src/components/nostr/kinds/CurationSetRenderer.tsx
new file mode 100644
index 0000000..0b0f2e8
--- /dev/null
+++ b/src/components/nostr/kinds/CurationSetRenderer.tsx
@@ -0,0 +1,240 @@
+import { Library, FileText, Video, Image } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import {
+ getEventPointerFromETag,
+ getAddressPointerFromATag,
+} from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { EventRefListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract event pointers from e tags
+ */
+function getEventPointers(event: NostrEvent): EventPointer[] {
+ const pointers: EventPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "e" && tag[1]) {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Extract address pointers from a tags
+ */
+function getAddressPointers(event: NostrEvent): AddressPointer[] {
+ const pointers: AddressPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "a" && tag[1]) {
+ const pointer = getAddressPointerFromATag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+interface CurationSetRendererProps extends BaseEventProps {
+ /** Icon to display */
+ icon: React.ReactNode;
+ /** Color class for the icon */
+ iconColor?: string;
+ /** Label for the content type */
+ contentLabel: string;
+}
+
+/**
+ * Generic Curation Set Renderer component
+ * Used by ArticleCurationSetRenderer, VideoCurationSetRenderer, PictureCurationSetRenderer
+ */
+function GenericCurationSetRenderer({
+ event,
+ icon,
+ contentLabel,
+}: CurationSetRendererProps) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+
+ const totalItems = eventPointers.length + addressPointers.length;
+
+ return (
+
+
+
+ {icon}
+ {title}
+
+
+ {totalItems === 0 ? (
+
+ Empty {contentLabel.toLowerCase()} set
+
+ ) : (
+
+
+
+ {totalItems} {contentLabel.toLowerCase()}
+
+
+ )}
+
+
+ );
+}
+
+/**
+ * Generic Curation Set Detail Renderer
+ */
+function GenericCurationSetDetailRenderer({
+ event,
+ icon,
+ contentLabel,
+}: {
+ event: NostrEvent;
+ icon: React.ReactNode;
+ contentLabel: string;
+}) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const description = getTagValue(event, "description");
+ const image = getTagValue(event, "image");
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+
+ return (
+
+
+ {image && (
+

+ )}
+
+ {icon}
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+
+
}
+ />
+
+ );
+}
+
+/**
+ * Kind 30004 Renderer - Article Curation Set (Feed View)
+ * NIP-51 curated collection of articles
+ */
+export function ArticleCurationSetRenderer({ event }: BaseEventProps) {
+ return (
+ }
+ contentLabel="Articles"
+ />
+ );
+}
+
+/**
+ * Kind 30004 Detail View
+ */
+export function ArticleCurationSetDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ return (
+ }
+ contentLabel="Articles"
+ />
+ );
+}
+
+/**
+ * Kind 30005 Renderer - Video Curation Set (Feed View)
+ * NIP-51 curated collection of videos
+ */
+export function VideoCurationSetRenderer({ event }: BaseEventProps) {
+ return (
+ }
+ contentLabel="Videos"
+ />
+ );
+}
+
+/**
+ * Kind 30005 Detail View
+ */
+export function VideoCurationSetDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ return (
+ }
+ contentLabel="Videos"
+ />
+ );
+}
+
+/**
+ * Kind 30006 Renderer - Picture Curation Set (Feed View)
+ * NIP-51 curated collection of pictures
+ */
+export function PictureCurationSetRenderer({ event }: BaseEventProps) {
+ return (
+ }
+ contentLabel="Pictures"
+ />
+ );
+}
+
+/**
+ * Kind 30006 Detail View
+ */
+export function PictureCurationSetDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ return (
+ }
+ contentLabel="Pictures"
+ />
+ );
+}
diff --git a/src/components/nostr/kinds/EmojiListRenderer.tsx b/src/components/nostr/kinds/EmojiListRenderer.tsx
new file mode 100644
index 0000000..71838b3
--- /dev/null
+++ b/src/components/nostr/kinds/EmojiListRenderer.tsx
@@ -0,0 +1,148 @@
+import { Smile } from "lucide-react";
+import { getAddressPointerFromATag } from "applesauce-core/helpers";
+import { getEmojiTags } from "@/lib/emoji-helpers";
+import { CustomEmoji } from "@/components/nostr/CustomEmoji";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { EventRefListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { AddressPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract address pointers from a tags (for emoji set references)
+ */
+function getEmojiSetPointers(event: NostrEvent): AddressPointer[] {
+ const pointers: AddressPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "a" && tag[1]) {
+ const pointer = getAddressPointerFromATag(tag);
+ // Only include kind 30030 (emoji sets)
+ if (pointer && pointer.kind === 30030) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 10030 Renderer - User Emoji List (Feed View)
+ * NIP-51 list of favorite/preferred emojis
+ */
+export function EmojiListRenderer({ event }: BaseEventProps) {
+ const emojis = getEmojiTags(event);
+ const emojiSets = getEmojiSetPointers(event);
+
+ // Show first 8 emojis in preview
+ const previewEmojis = emojis.slice(0, 8);
+ const remainingCount = emojis.length - previewEmojis.length;
+
+ if (emojis.length === 0 && emojiSets.length === 0) {
+ return (
+
+
+ No emojis configured
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Emoji Preferences
+
+
+ {emojis.length > 0 && (
+
+ {previewEmojis.map((emoji) => (
+
+ ))}
+ {remainingCount > 0 && (
+
+ +{remainingCount} more
+
+ )}
+
+ )}
+
+ {emojiSets.length > 0 && (
+
+ + {emojiSets.length} emoji sets
+
+ )}
+
+
+ {emojis.length} emoji{emojis.length !== 1 ? "s" : ""}
+
+
+
+ );
+}
+
+/**
+ * Kind 10030 Detail View - Full emoji list
+ */
+export function EmojiListDetailRenderer({ event }: { event: NostrEvent }) {
+ const emojis = getEmojiTags(event);
+ const emojiSets = getEmojiSetPointers(event);
+
+ return (
+
+
+
+ Emoji Preferences
+
+
+ {emojis.length > 0 && (
+
+
Custom Emojis ({emojis.length})
+
+ {emojis.map((emoji) => (
+
+
+
+ :{emoji.shortcode}:
+
+
+ ))}
+
+
+ )}
+
+ {emojiSets.length > 0 && (
+
}
+ />
+ )}
+
+ {emojis.length === 0 && emojiSets.length === 0 && (
+
+ No emojis configured
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/FollowSetRenderer.tsx b/src/components/nostr/kinds/FollowSetRenderer.tsx
new file mode 100644
index 0000000..fd84a5b
--- /dev/null
+++ b/src/components/nostr/kinds/FollowSetRenderer.tsx
@@ -0,0 +1,81 @@
+import { Users } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import { getTagValues } from "@/lib/nostr-utils";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { PubkeyListPreview, PubkeyListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+
+/**
+ * Kind 30000 Renderer - Follow Set (Feed View)
+ * NIP-51 parameterized list of pubkeys to follow
+ * Each set has a unique identifier (d tag) like "friends", "work", etc.
+ */
+export function FollowSetRenderer({ event }: BaseEventProps) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+
+
+ {title}
+
+
+ {pubkeys.length === 0 ? (
+
+ Empty follow set
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+/**
+ * Kind 30000 Detail View - Full follow set
+ */
+export function FollowSetDetailRenderer({ event }: { event: NostrEvent }) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const description = getTagValue(event, "description");
+ const image = getTagValue(event, "image");
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+ {image && (
+

+ )}
+
+
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/InterestListRenderer.tsx b/src/components/nostr/kinds/InterestListRenderer.tsx
new file mode 100644
index 0000000..294d5d1
--- /dev/null
+++ b/src/components/nostr/kinds/InterestListRenderer.tsx
@@ -0,0 +1,178 @@
+import { Sparkles } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import { getTagValues } from "@/lib/nostr-utils";
+import { getAddressPointerFromATag } from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import {
+ HashtagListPreview,
+ HashtagListFull,
+ EventRefListFull,
+} from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { AddressPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract address pointers from a tags (for interest sets references)
+ */
+function getAddressPointers(event: NostrEvent): AddressPointer[] {
+ const pointers: AddressPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "a" && tag[1]) {
+ const pointer = getAddressPointerFromATag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 10015 Renderer - Interest List (Feed View)
+ * NIP-51 list of topics/hashtags of interest
+ */
+export function InterestListRenderer({ event }: BaseEventProps) {
+ const hashtags = getTagValues(event, "t");
+ const interestSets = getAddressPointers(event);
+
+ const totalItems = hashtags.length + interestSets.length;
+
+ if (totalItems === 0) {
+ return (
+
+
+ No interests configured
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Interests
+
+
+ {hashtags.length > 0 && (
+
+ )}
+
+ {interestSets.length > 0 && (
+
+ + {interestSets.length} interest sets
+
+ )}
+
+
+ );
+}
+
+/**
+ * Kind 10015 Detail View - Full interest list
+ */
+export function InterestListDetailRenderer({ event }: { event: NostrEvent }) {
+ const hashtags = getTagValues(event, "t");
+ const interestSets = getAddressPointers(event);
+
+ return (
+
+
+
+ Interests
+
+
+ {hashtags.length > 0 && (
+
+ )}
+
+ {interestSets.length > 0 && (
+
}
+ />
+ )}
+
+ {hashtags.length === 0 && interestSets.length === 0 && (
+
+ No interests configured
+
+ )}
+
+ );
+}
+
+/**
+ * Kind 30015 Renderer - Interest Set (Feed View)
+ * NIP-51 parameterized list of interest topics
+ */
+export function InterestSetRenderer({ event }: BaseEventProps) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const hashtags = getTagValues(event, "t");
+
+ return (
+
+
+
+
+ {title}
+
+
+ {hashtags.length === 0 ? (
+
+ Empty interest set
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+/**
+ * Kind 30015 Detail View - Full interest set
+ */
+export function InterestSetDetailRenderer({ event }: { event: NostrEvent }) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const description = getTagValue(event, "description");
+ const image = getTagValue(event, "image");
+ const hashtags = getTagValues(event, "t");
+
+ return (
+
+
+ {image && (
+

+ )}
+
+
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/KindMuteSetRenderer.tsx b/src/components/nostr/kinds/KindMuteSetRenderer.tsx
new file mode 100644
index 0000000..7a790da
--- /dev/null
+++ b/src/components/nostr/kinds/KindMuteSetRenderer.tsx
@@ -0,0 +1,93 @@
+import { VolumeX, Users } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import { getTagValues } from "@/lib/nostr-utils";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { PubkeyListPreview, PubkeyListFull } from "../lists";
+import { KindBadge } from "@/components/KindBadge";
+import type { NostrEvent } from "@/types/nostr";
+
+/**
+ * Kind 30007 Renderer - Kind Mute Set (Feed View)
+ * NIP-51 parameterized list for muting events of a specific kind
+ * The d tag contains the kind number to mute
+ */
+export function KindMuteSetRenderer({ event }: BaseEventProps) {
+ const kindStr = getTagValue(event, "d") || "0";
+ const kindNumber = parseInt(kindStr, 10);
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+
+
+ Kind Mute
+
+
+
+ Muting kind:
+
+
+
+ {pubkeys.length > 0 && (
+
}
+ />
+ )}
+
+ {pubkeys.length === 0 && (
+
+ Muting all authors for this kind
+
+ )}
+
+
+ );
+}
+
+/**
+ * Kind 30007 Detail View - Full kind mute set
+ */
+export function KindMuteSetDetailRenderer({ event }: { event: NostrEvent }) {
+ const kindStr = getTagValue(event, "d") || "0";
+ const kindNumber = parseInt(kindStr, 10);
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+
+ Kind Mute Set
+
+
+
+
+ {pubkeys.length > 0 ? (
+
}
+ />
+ ) : (
+
+ All authors are muted for this kind
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/MediaFollowListRenderer.tsx b/src/components/nostr/kinds/MediaFollowListRenderer.tsx
new file mode 100644
index 0000000..583549d
--- /dev/null
+++ b/src/components/nostr/kinds/MediaFollowListRenderer.tsx
@@ -0,0 +1,74 @@
+import { Video, Users } from "lucide-react";
+import { getTagValues } from "@/lib/nostr-utils";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { PubkeyListPreview, PubkeyListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+
+/**
+ * Kind 10020 Renderer - Media Follow List (Feed View)
+ * NIP-51 list of media creators to follow
+ */
+export function MediaFollowListRenderer({ event }: BaseEventProps) {
+ const pubkeys = getTagValues(event, "p");
+
+ if (pubkeys.length === 0) {
+ return (
+
+
+ No media creators followed
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Media Follows
+
+
+
}
+ />
+
+
+ );
+}
+
+/**
+ * Kind 10020 Detail View - Full media follow list
+ */
+export function MediaFollowListDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+
+ Media Follows
+
+
+
}
+ />
+
+ );
+}
diff --git a/src/components/nostr/kinds/MuteListRenderer.tsx b/src/components/nostr/kinds/MuteListRenderer.tsx
new file mode 100644
index 0000000..cb40fb1
--- /dev/null
+++ b/src/components/nostr/kinds/MuteListRenderer.tsx
@@ -0,0 +1,150 @@
+import { VolumeX, Users, Hash, Type, FileText } from "lucide-react";
+import { getTagValues } from "@/lib/nostr-utils";
+import { getEventPointerFromETag } from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import {
+ PubkeyListFull,
+ HashtagListFull,
+ WordListFull,
+ EventRefListFull,
+} from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract event pointers from e tags
+ */
+function getEventPointers(event: NostrEvent): EventPointer[] {
+ const pointers: EventPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "e" && tag[1]) {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 10000 Renderer - Mute List (Feed View)
+ * NIP-51 list of muted pubkeys, hashtags, words, and threads
+ */
+export function MuteListRenderer({ event }: BaseEventProps) {
+ const mutedPubkeys = getTagValues(event, "p");
+ const mutedHashtags = getTagValues(event, "t");
+ const mutedWords = getTagValues(event, "word");
+ const mutedThreads = getEventPointers(event);
+
+ const totalMuted =
+ mutedPubkeys.length +
+ mutedHashtags.length +
+ mutedWords.length +
+ mutedThreads.length;
+
+ if (totalMuted === 0) {
+ return (
+
+
+ Empty mute list
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Mute List
+
+
+
+ {mutedPubkeys.length > 0 && (
+
+
+ {mutedPubkeys.length} people
+
+ )}
+ {mutedHashtags.length > 0 && (
+
+
+ {mutedHashtags.length} topics
+
+ )}
+ {mutedWords.length > 0 && (
+
+
+ {mutedWords.length} words
+
+ )}
+ {mutedThreads.length > 0 && (
+
+
+ {mutedThreads.length} threads
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * Kind 10000 Detail View - Full mute list
+ */
+export function MuteListDetailRenderer({ event }: { event: NostrEvent }) {
+ const mutedPubkeys = getTagValues(event, "p");
+ const mutedHashtags = getTagValues(event, "t");
+ const mutedWords = getTagValues(event, "word");
+ const mutedThreads = getEventPointers(event);
+
+ return (
+
+
+
+ Mute List
+
+
+ {mutedPubkeys.length > 0 && (
+
}
+ />
+ )}
+
+ {mutedHashtags.length > 0 && (
+
+ )}
+
+ {mutedWords.length > 0 &&
}
+
+ {mutedThreads.length > 0 && (
+
}
+ />
+ )}
+
+ {mutedPubkeys.length === 0 &&
+ mutedHashtags.length === 0 &&
+ mutedWords.length === 0 &&
+ mutedThreads.length === 0 && (
+
+ Empty mute list
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/PinListRenderer.tsx b/src/components/nostr/kinds/PinListRenderer.tsx
new file mode 100644
index 0000000..a29a602
--- /dev/null
+++ b/src/components/nostr/kinds/PinListRenderer.tsx
@@ -0,0 +1,117 @@
+import { Pin, FileText } from "lucide-react";
+import {
+ getEventPointerFromETag,
+ getAddressPointerFromATag,
+} from "applesauce-core/helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { EventRefListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
+
+/**
+ * Extract event pointers from e tags
+ */
+function getEventPointers(event: NostrEvent): EventPointer[] {
+ const pointers: EventPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "e" && tag[1]) {
+ const pointer = getEventPointerFromETag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Extract address pointers from a tags
+ */
+function getAddressPointers(event: NostrEvent): AddressPointer[] {
+ const pointers: AddressPointer[] = [];
+ for (const tag of event.tags) {
+ if (tag[0] === "a" && tag[1]) {
+ const pointer = getAddressPointerFromATag(tag);
+ if (pointer) {
+ pointers.push(pointer);
+ }
+ }
+ }
+ return pointers;
+}
+
+/**
+ * Kind 10001 Renderer - Pin List (Feed View)
+ * NIP-51 list of pinned events
+ */
+export function PinListRenderer({ event }: BaseEventProps) {
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+
+ const totalItems = eventPointers.length + addressPointers.length;
+
+ if (totalItems === 0) {
+ return (
+
+
+ No pinned items
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Pinned
+
+
+
+
+ {totalItems} pinned items
+
+
+
+ );
+}
+
+/**
+ * Kind 10001 Detail View - Full pin list
+ */
+export function PinListDetailRenderer({ event }: { event: NostrEvent }) {
+ const eventPointers = getEventPointers(event);
+ const addressPointers = getAddressPointers(event);
+
+ return (
+
+
+
+ {(eventPointers.length > 0 || addressPointers.length > 0) && (
+
}
+ />
+ )}
+
+ {eventPointers.length === 0 && addressPointers.length === 0 && (
+
+ No pinned items
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/StarterPackRenderer.tsx b/src/components/nostr/kinds/StarterPackRenderer.tsx
new file mode 100644
index 0000000..637667e
--- /dev/null
+++ b/src/components/nostr/kinds/StarterPackRenderer.tsx
@@ -0,0 +1,162 @@
+import { Package, Users, Video } from "lucide-react";
+import { getTagValue } from "applesauce-core/helpers";
+import { getTagValues } from "@/lib/nostr-utils";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { PubkeyListPreview, PubkeyListFull } from "../lists";
+import type { NostrEvent } from "@/types/nostr";
+
+/**
+ * Kind 39089 Renderer - Starter Pack (Feed View)
+ * NIP-51 new user onboarding pack with recommended follows
+ */
+export function StarterPackRenderer({ event }: BaseEventProps) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+
+
+ {title}
+
+
+ {pubkeys.length === 0 ? (
+
+ Empty starter pack
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+/**
+ * Kind 39089 Detail View - Full starter pack
+ */
+export function StarterPackDetailRenderer({ event }: { event: NostrEvent }) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const description = getTagValue(event, "description");
+ const image = getTagValue(event, "image");
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+ {image && (
+

+ )}
+
+ {description && (
+
{description}
+ )}
+
+
+
}
+ />
+
+ );
+}
+
+/**
+ * Kind 39092 Renderer - Media Starter Pack (Feed View)
+ * NIP-51 media creator starter pack
+ */
+export function MediaStarterPackRenderer({ event }: BaseEventProps) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+
+
+ {title}
+
+
+ {pubkeys.length === 0 ? (
+
+ Empty media starter pack
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+/**
+ * Kind 39092 Detail View - Full media starter pack
+ */
+export function MediaStarterPackDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ const identifier = getTagValue(event, "d") || "unnamed";
+ const title = getTagValue(event, "title") || identifier;
+ const description = getTagValue(event, "description");
+ const image = getTagValue(event, "image");
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+ {image && (
+

+ )}
+
+
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+
+
}
+ />
+
+ );
+}
diff --git a/src/components/nostr/kinds/WikiListRenderer.tsx b/src/components/nostr/kinds/WikiListRenderer.tsx
new file mode 100644
index 0000000..94407c5
--- /dev/null
+++ b/src/components/nostr/kinds/WikiListRenderer.tsx
@@ -0,0 +1,148 @@
+import { BookOpen, Users, Server } from "lucide-react";
+import { getTagValues } from "@/lib/nostr-utils";
+import { getRelaysFromList } from "applesauce-common/helpers/lists";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { PubkeyListPreview, PubkeyListFull } from "../lists";
+import { RelayLink } from "../RelayLink";
+import type { NostrEvent } from "@/types/nostr";
+
+/**
+ * Kind 10101 Renderer - Good Wiki Authors (Feed View)
+ * NIP-51 list of trusted wiki contributors
+ */
+export function WikiAuthorsRenderer({ event }: BaseEventProps) {
+ const pubkeys = getTagValues(event, "p");
+
+ if (pubkeys.length === 0) {
+ return (
+
+
+ No trusted wiki authors
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Wiki Authors
+
+
+
}
+ />
+
+
+ );
+}
+
+/**
+ * Kind 10101 Detail View - Full wiki authors list
+ */
+export function WikiAuthorsDetailRenderer({ event }: { event: NostrEvent }) {
+ const pubkeys = getTagValues(event, "p");
+
+ return (
+
+
+
+ Trusted Wiki Authors
+
+
+
}
+ />
+
+ );
+}
+
+/**
+ * Kind 10102 Renderer - Good Wiki Relays (Feed View)
+ * NIP-51 list of trusted wiki relays
+ */
+export function WikiRelaysRenderer({ event }: BaseEventProps) {
+ const relays = getRelaysFromList(event, "all");
+
+ if (relays.length === 0) {
+ return (
+
+
+ No trusted wiki relays
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Wiki Relays
+
+
+
+
+ {relays.length} trusted relays
+
+
+
+ );
+}
+
+/**
+ * Kind 10102 Detail View - Full wiki relays list
+ */
+export function WikiRelaysDetailRenderer({ event }: { event: NostrEvent }) {
+ const relays = getRelaysFromList(event, "all");
+
+ return (
+
+
+
+ Trusted Wiki Relays
+
+
+ {relays.length > 0 ? (
+
+
+
+ Relays ({relays.length})
+
+
+ {relays.map((url) => (
+
+ ))}
+
+
+ ) : (
+
+ No trusted wiki relays
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 842b3fc..0494f88 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -67,6 +67,67 @@ import { ZapstoreAppSetDetailRenderer } from "./ZapstoreAppSetDetailRenderer";
import { ZapstoreReleaseRenderer } from "./ZapstoreReleaseRenderer";
import { ZapstoreReleaseDetailRenderer } from "./ZapstoreReleaseDetailRenderer";
import { GroupMetadataRenderer } from "./GroupMetadataRenderer";
+// NIP-51 List Renderers
+import { MuteListRenderer, MuteListDetailRenderer } from "./MuteListRenderer";
+import { PinListRenderer, PinListDetailRenderer } from "./PinListRenderer";
+import {
+ BookmarkListRenderer,
+ BookmarkListDetailRenderer,
+} from "./BookmarkListRenderer";
+import {
+ CommunityListRenderer,
+ CommunityListDetailRenderer,
+} from "./CommunityListRenderer";
+import {
+ ChannelListRenderer,
+ ChannelListDetailRenderer,
+} from "./ChannelListRenderer";
+import {
+ InterestListRenderer,
+ InterestListDetailRenderer,
+ InterestSetRenderer,
+ InterestSetDetailRenderer,
+} from "./InterestListRenderer";
+import {
+ MediaFollowListRenderer,
+ MediaFollowListDetailRenderer,
+} from "./MediaFollowListRenderer";
+import {
+ EmojiListRenderer,
+ EmojiListDetailRenderer,
+} from "./EmojiListRenderer";
+import {
+ WikiAuthorsRenderer,
+ WikiAuthorsDetailRenderer,
+ WikiRelaysRenderer,
+ WikiRelaysDetailRenderer,
+} from "./WikiListRenderer";
+import {
+ FollowSetRenderer,
+ FollowSetDetailRenderer,
+} from "./FollowSetRenderer";
+import {
+ BookmarkSetRenderer,
+ BookmarkSetDetailRenderer,
+} from "./BookmarkSetRenderer";
+import {
+ ArticleCurationSetRenderer,
+ ArticleCurationSetDetailRenderer,
+ VideoCurationSetRenderer,
+ VideoCurationSetDetailRenderer,
+ PictureCurationSetRenderer,
+ PictureCurationSetDetailRenderer,
+} from "./CurationSetRenderer";
+import {
+ KindMuteSetRenderer,
+ KindMuteSetDetailRenderer,
+} from "./KindMuteSetRenderer";
+import {
+ StarterPackRenderer,
+ StarterPackDetailRenderer,
+ MediaStarterPackRenderer,
+ MediaStarterPackDetailRenderer,
+} from "./StarterPackRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
@@ -99,15 +160,32 @@ const kindRenderers: Record> = {
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
777: SpellRenderer, // Spell (Grimoire)
+ 10000: MuteListRenderer, // Mute List (NIP-51)
+ 10001: PinListRenderer, // Pin List (NIP-51)
10002: Kind10002Renderer, // Relay List Metadata (NIP-65)
- 10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
- 10317: Kind10317Renderer, // User Grasp List (NIP-34)
+ 10003: BookmarkListRenderer, // Bookmark List (NIP-51)
+ 10004: CommunityListRenderer, // Community List (NIP-51)
+ 10005: ChannelListRenderer, // Public Chats/Channels List (NIP-51)
10006: GenericRelayListRenderer, // Blocked Relays (NIP-51)
10007: GenericRelayListRenderer, // Search Relays (NIP-51)
- 10009: PublicChatsRenderer, // Public Chats List (NIP-51)
+ 10009: PublicChatsRenderer, // User Groups List (NIP-51)
10012: GenericRelayListRenderer, // Favorite Relays (NIP-51)
+ 10015: InterestListRenderer, // Interest List (NIP-51)
+ 10020: MediaFollowListRenderer, // Media Follow List (NIP-51)
+ 10030: EmojiListRenderer, // User Emoji List (NIP-51)
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
+ 10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
+ 10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
+ 10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
+ 10317: Kind10317Renderer, // User Grasp List (NIP-34)
+ 30000: FollowSetRenderer, // Follow Sets (NIP-51)
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
+ 30003: BookmarkSetRenderer, // Bookmark Sets (NIP-51)
+ 30004: ArticleCurationSetRenderer, // Article Curation Sets (NIP-51)
+ 30005: VideoCurationSetRenderer, // Video Curation Sets (NIP-51)
+ 30006: PictureCurationSetRenderer, // Picture Curation Sets (NIP-51)
+ 30007: KindMuteSetRenderer, // Kind Mute Sets (NIP-51)
+ 30015: InterestSetRenderer, // Interest Sets (NIP-51)
30023: Kind30023Renderer, // Long-form Article
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
30063: ZapstoreReleaseRenderer, // Zapstore App Release
@@ -125,6 +203,8 @@ const kindRenderers: Record> = {
31990: ApplicationHandlerRenderer, // Application Handler (NIP-89)
32267: ZapstoreAppRenderer, // Zapstore App
39000: GroupMetadataRenderer, // Group Metadata (NIP-29)
+ 39089: StarterPackRenderer, // Starter Pack (NIP-51)
+ 39092: MediaStarterPackRenderer, // Media Starter Pack (NIP-51)
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
};
@@ -169,15 +249,32 @@ const detailRenderers: Record<
> = {
0: Kind0DetailRenderer, // Profile Metadata Detail
3: Kind3DetailView, // Contact List Detail
+ 777: SpellDetailRenderer, // Spell Detail
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34)
1621: IssueDetailRenderer, // Issue Detail (NIP-34)
9802: Kind9802DetailRenderer, // Highlight Detail
+ 10000: MuteListDetailRenderer, // Mute List Detail (NIP-51)
+ 10001: PinListDetailRenderer, // Pin List Detail (NIP-51)
10002: Kind10002DetailRenderer, // Relay List Detail (NIP-65)
+ 10003: BookmarkListDetailRenderer, // Bookmark List Detail (NIP-51)
+ 10004: CommunityListDetailRenderer, // Community List Detail (NIP-51)
+ 10005: ChannelListDetailRenderer, // Channel List Detail (NIP-51)
+ 10015: InterestListDetailRenderer, // Interest List Detail (NIP-51)
+ 10020: MediaFollowListDetailRenderer, // Media Follow List Detail (NIP-51)
+ 10030: EmojiListDetailRenderer, // User Emoji List Detail (NIP-51)
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
+ 10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51)
+ 10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51)
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
- 777: SpellDetailRenderer, // Spell Detail
+ 30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
+ 30003: BookmarkSetDetailRenderer, // Bookmark Sets Detail (NIP-51)
+ 30004: ArticleCurationSetDetailRenderer, // Article Curation Sets Detail (NIP-51)
+ 30005: VideoCurationSetDetailRenderer, // Video Curation Sets Detail (NIP-51)
+ 30006: PictureCurationSetDetailRenderer, // Picture Curation Sets Detail (NIP-51)
+ 30007: KindMuteSetDetailRenderer, // Kind Mute Sets Detail (NIP-51)
+ 30015: InterestSetDetailRenderer, // Interest Sets Detail (NIP-51)
30023: Kind30023DetailRenderer, // Long-form Article Detail
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail
@@ -192,6 +289,8 @@ const detailRenderers: Record<
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)
31990: ApplicationHandlerDetailRenderer, // Application Handler Detail (NIP-89)
32267: ZapstoreAppDetailRenderer, // Zapstore App Detail
+ 39089: StarterPackDetailRenderer, // Starter Pack Detail (NIP-51)
+ 39092: MediaStarterPackDetailRenderer, // Media Starter Pack Detail (NIP-51)
};
/**
diff --git a/src/components/nostr/lists/EventRefList.tsx b/src/components/nostr/lists/EventRefList.tsx
new file mode 100644
index 0000000..f0543d3
--- /dev/null
+++ b/src/components/nostr/lists/EventRefList.tsx
@@ -0,0 +1,167 @@
+import { FileText, ExternalLink } from "lucide-react";
+import { useGrimoire } from "@/core/state";
+import { cn } from "@/lib/utils";
+import { EmbeddedEvent } from "../EmbeddedEvent";
+import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
+
+interface EventRefListPreviewProps {
+ /** Event pointers (from e tags) */
+ eventPointers?: EventPointer[];
+ /** Address pointers (from a tags) */
+ addressPointers?: AddressPointer[];
+ /** Label for the count */
+ label?: string;
+ /** Icon to show */
+ icon?: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Compact preview of event references
+ * Shows count of referenced events/addresses
+ */
+export function EventRefListPreview({
+ eventPointers = [],
+ addressPointers = [],
+ label = "items",
+ icon,
+ className,
+}: EventRefListPreviewProps) {
+ const total = eventPointers.length + addressPointers.length;
+
+ if (total === 0) {
+ return (
+
+ No {label}
+
+ );
+ }
+
+ return (
+
+ {icon || }
+
+ {total} {label}
+
+
+ );
+}
+
+interface EventRefItemProps {
+ eventPointer?: EventPointer;
+ addressPointer?: AddressPointer;
+}
+
+/**
+ * Single clickable event reference
+ */
+export function EventRefItem({
+ eventPointer,
+ addressPointer,
+}: EventRefItemProps) {
+ const { addWindow } = useGrimoire();
+
+ const handleClick = () => {
+ if (eventPointer) {
+ addWindow("open", { pointer: eventPointer });
+ } else if (addressPointer) {
+ addWindow("open", { pointer: addressPointer });
+ }
+ };
+
+ const displayText = eventPointer
+ ? `${eventPointer.id.slice(0, 8)}...`
+ : addressPointer
+ ? addressPointer.identifier || `${addressPointer.kind}`
+ : "unknown";
+
+ return (
+
+
+
+ {displayText}
+
+
+ );
+}
+
+interface EventRefListFullProps {
+ /** Event pointers (from e tags) */
+ eventPointers?: EventPointer[];
+ /** Address pointers (from a tags) */
+ addressPointers?: AddressPointer[];
+ /** Label for the section header */
+ label?: string;
+ /** Icon for the header */
+ icon?: React.ReactNode;
+ /** Show embedded events instead of links */
+ embedded?: boolean;
+ className?: string;
+}
+
+/**
+ * Full list of event references for detail views
+ * When embedded=true, shows full event renderers for each reference
+ */
+export function EventRefListFull({
+ eventPointers = [],
+ addressPointers = [],
+ label = "Items",
+ icon,
+ embedded = true,
+ className,
+}: EventRefListFullProps) {
+ const total = eventPointers.length + addressPointers.length;
+
+ if (total === 0) {
+ return (
+
+ No {label.toLowerCase()}
+
+ );
+ }
+
+ return (
+
+
+ {icon || }
+
+ {label} ({total})
+
+
+ {embedded ? (
+
+ {eventPointers.map((pointer) => (
+
+ ))}
+ {addressPointers.map((pointer) => (
+
+ ))}
+
+ ) : (
+
+ {eventPointers.map((pointer) => (
+
+ ))}
+ {addressPointers.map((pointer) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/lists/HashtagListPreview.tsx b/src/components/nostr/lists/HashtagListPreview.tsx
new file mode 100644
index 0000000..5e274fe
--- /dev/null
+++ b/src/components/nostr/lists/HashtagListPreview.tsx
@@ -0,0 +1,97 @@
+import { Hash } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+
+interface HashtagListPreviewProps {
+ hashtags: string[];
+ /** Maximum number of hashtags to show in preview */
+ previewLimit?: number;
+ /** Label for the count */
+ label?: string;
+ className?: string;
+}
+
+/**
+ * Compact preview of a list of hashtags
+ * Shows count and optionally previews first few tags
+ */
+export function HashtagListPreview({
+ hashtags,
+ previewLimit = 5,
+ label = "topics",
+ className,
+}: HashtagListPreviewProps) {
+ if (hashtags.length === 0) {
+ return (
+
+ No {label}
+
+ );
+ }
+
+ const previewTags = hashtags.slice(0, previewLimit);
+ const remaining = hashtags.length - previewTags.length;
+
+ return (
+
+
+
+
+ {hashtags.length} {label}
+
+
+
+ {previewTags.map((tag) => (
+
+ ))}
+ {remaining > 0 && (
+
+ +{remaining} more
+
+ )}
+
+
+ );
+}
+
+interface HashtagListFullProps {
+ hashtags: string[];
+ /** Label for the section header */
+ label?: string;
+ className?: string;
+}
+
+/**
+ * Full list of hashtags for detail views
+ */
+export function HashtagListFull({
+ hashtags,
+ label = "Topics",
+ className,
+}: HashtagListFullProps) {
+ if (hashtags.length === 0) {
+ return (
+
+ No {label.toLowerCase()}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {label} ({hashtags.length})
+
+
+
+ {hashtags.map((tag) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/nostr/lists/PubkeyListPreview.tsx b/src/components/nostr/lists/PubkeyListPreview.tsx
new file mode 100644
index 0000000..e4b8d5e
--- /dev/null
+++ b/src/components/nostr/lists/PubkeyListPreview.tsx
@@ -0,0 +1,112 @@
+import { Users } from "lucide-react";
+import { UserName } from "../UserName";
+import { cn } from "@/lib/utils";
+
+interface PubkeyListPreviewProps {
+ pubkeys: string[];
+ /** Maximum number of pubkeys to show in preview */
+ previewLimit?: number;
+ /** Label for the count (e.g., "people", "users", "authors") */
+ label?: string;
+ /** Icon to show next to count */
+ icon?: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Compact preview of a list of pubkeys
+ * Shows count with icon and optionally previews first few names
+ */
+export function PubkeyListPreview({
+ pubkeys,
+ previewLimit = 0,
+ label = "people",
+ icon,
+ className,
+}: PubkeyListPreviewProps) {
+ // Filter to valid pubkeys (64 char hex)
+ const validPubkeys = pubkeys.filter((pk) => pk.length === 64);
+
+ if (validPubkeys.length === 0) {
+ return (
+
+ No {label}
+
+ );
+ }
+
+ const previewPubkeys =
+ previewLimit > 0 ? validPubkeys.slice(0, previewLimit) : [];
+ const remaining = validPubkeys.length - previewPubkeys.length;
+
+ return (
+
+
+ {icon || }
+
+ {validPubkeys.length} {label}
+
+
+ {previewPubkeys.length > 0 && (
+
+ {previewPubkeys.map((pubkey) => (
+
+ ))}
+ {remaining > 0 && (
+ +{remaining} more
+ )}
+
+ )}
+
+ );
+}
+
+interface PubkeyListFullProps {
+ pubkeys: string[];
+ /** Label for the section header */
+ label?: string;
+ /** Icon to show in header */
+ icon?: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Full list of pubkeys for detail views
+ * Shows all pubkeys as clickable names
+ */
+export function PubkeyListFull({
+ pubkeys,
+ label = "People",
+ icon,
+ className,
+}: PubkeyListFullProps) {
+ // Filter to valid pubkeys (64 char hex)
+ const validPubkeys = pubkeys.filter((pk) => pk.length === 64);
+
+ if (validPubkeys.length === 0) {
+ return (
+
+ No {label.toLowerCase()}
+
+ );
+ }
+
+ return (
+
+
+ {icon || }
+
+ {label} ({validPubkeys.length})
+
+
+
+ {validPubkeys.map((pubkey) => (
+
+ •
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/nostr/lists/UrlList.tsx b/src/components/nostr/lists/UrlList.tsx
new file mode 100644
index 0000000..c3e8bf6
--- /dev/null
+++ b/src/components/nostr/lists/UrlList.tsx
@@ -0,0 +1,125 @@
+import { Link, ExternalLink } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface UrlListPreviewProps {
+ urls: string[];
+ /** Maximum number of URLs to show */
+ previewLimit?: number;
+ /** Label for the count */
+ label?: string;
+ className?: string;
+}
+
+/**
+ * Compact preview of a URL list
+ */
+export function UrlListPreview({
+ urls,
+ previewLimit = 2,
+ label = "links",
+ className,
+}: UrlListPreviewProps) {
+ if (urls.length === 0) {
+ return null;
+ }
+
+ const previewUrls = urls.slice(0, previewLimit);
+ const remaining = urls.length - previewUrls.length;
+
+ return (
+
+
+
+
+ {urls.length} {label}
+
+
+
+ {previewUrls.map((url) => (
+
+ ))}
+ {remaining > 0 && (
+
+ +{remaining} more
+
+ )}
+
+
+ );
+}
+
+interface UrlItemProps {
+ url: string;
+ compact?: boolean;
+}
+
+/**
+ * Single clickable URL
+ */
+export function UrlItem({ url, compact }: UrlItemProps) {
+ // Extract domain for display
+ let displayUrl: string;
+ try {
+ const parsed = new URL(url);
+ displayUrl = compact ? parsed.hostname : url;
+ } catch {
+ displayUrl = url;
+ }
+
+ return (
+
+
+ {displayUrl}
+
+ );
+}
+
+interface UrlListFullProps {
+ urls: string[];
+ /** Label for the section header */
+ label?: string;
+ className?: string;
+}
+
+/**
+ * Full list of URLs for detail views
+ */
+export function UrlListFull({
+ urls,
+ label = "Links",
+ className,
+}: UrlListFullProps) {
+ if (urls.length === 0) {
+ return (
+
+ No links
+
+ );
+ }
+
+ return (
+
+
+
+
+ {label} ({urls.length})
+
+
+
+ {urls.map((url) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/nostr/lists/WordList.tsx b/src/components/nostr/lists/WordList.tsx
new file mode 100644
index 0000000..b826a17
--- /dev/null
+++ b/src/components/nostr/lists/WordList.tsx
@@ -0,0 +1,101 @@
+import { Type } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Label } from "@/components/ui/label";
+
+interface WordListPreviewProps {
+ words: string[];
+ /** Maximum number of words to show */
+ previewLimit?: number;
+ /** Label for the count */
+ label?: string;
+ className?: string;
+}
+
+/**
+ * Compact preview of a word list (e.g., muted words)
+ */
+export function WordListPreview({
+ words,
+ previewLimit = 3,
+ label = "words",
+ className,
+}: WordListPreviewProps) {
+ if (words.length === 0) {
+ return null; // Don't show anything if no words
+ }
+
+ const previewWords = words.slice(0, previewLimit);
+ const remaining = words.length - previewWords.length;
+
+ return (
+
+
+
+
+ {words.length} muted {label}
+
+
+
+ {previewWords.map((word) => (
+
+ ))}
+ {remaining > 0 && (
+
+ +{remaining} more
+
+ )}
+
+
+ );
+}
+
+interface WordListFullProps {
+ words: string[];
+ /** Label for the section header */
+ label?: string;
+ className?: string;
+}
+
+/**
+ * Full list of words for detail views
+ */
+export function WordListFull({
+ words,
+ label = "Muted Words",
+ className,
+}: WordListFullProps) {
+ if (words.length === 0) {
+ return (
+
+ No muted words
+
+ );
+ }
+
+ return (
+
+
+
+
+ {label} ({words.length})
+
+
+
+ {words.map((word) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/nostr/lists/index.tsx b/src/components/nostr/lists/index.tsx
new file mode 100644
index 0000000..10dd492
--- /dev/null
+++ b/src/components/nostr/lists/index.tsx
@@ -0,0 +1,11 @@
+// Reusable list components for NIP-51 list rendering
+
+export { PubkeyListPreview, PubkeyListFull } from "./PubkeyListPreview";
+export { HashtagListPreview, HashtagListFull } from "./HashtagListPreview";
+export {
+ EventRefListPreview,
+ EventRefListFull,
+ EventRefItem,
+} from "./EventRefList";
+export { WordListPreview, WordListFull } from "./WordList";
+export { UrlListPreview, UrlListFull, UrlItem } from "./UrlList";