Review NIP-51 list types and rendering support (#77)

* Add comprehensive NIP-51 list rendering support

Implement rich renderers for all major NIP-51 list types with both
feed and detail views. Creates reusable list item components for
consistent UI across different list kinds.

New list renderers:
- Kind 10000: Mute List (pubkeys, hashtags, words, threads)
- Kind 10001: Pin List (pinned events/addresses)
- Kind 10003: Bookmark List (events, addresses, URLs)
- Kind 10004: Community List (community references)
- Kind 10005: Channel List (public chat channels)
- Kind 10015: Interest List (hashtags + interest sets)
- Kind 10020: Media Follow List (media creators)
- Kind 10030: User Emoji List (custom emojis)
- Kind 10101: Good Wiki Authors
- Kind 10102: Good Wiki Relays
- Kind 30000: Follow Sets (categorized follows)
- Kind 30003: Bookmark Sets (categorized bookmarks)
- Kind 30004: Article Curation Sets
- Kind 30005: Video Curation Sets
- Kind 30006: Picture Curation Sets
- Kind 30007: Kind Mute Sets
- Kind 30015: Interest Sets
- Kind 39089: Starter Packs
- Kind 39092: Media Starter Packs

Reusable components in src/components/nostr/lists/:
- PubkeyListPreview/Full: Display pubkey lists with counts
- HashtagListPreview/Full: Display hashtag pills
- EventRefList: Display event/address references
- WordList: Display muted words
- UrlList: Display URL bookmarks

* Improve NIP-51 list rendering with clickable titles and consistent styling

- Add ClickableEventTitle to all list feed renderers for detail view navigation
- Change colored icons to text-muted-foreground for consistent muted appearance
- Update HashtagListPreview to use Label component with dotted border styling
- Update EventRefList detail view to embed events using EmbeddedEvent component

* Use Label component for muted words with destructive styling

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-13 17:23:57 +01:00
committed by GitHub
parent 9ef1fefd3d
commit 4078ea372a
21 changed files with 2579 additions and 4 deletions

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
Empty bookmark list
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Bookmark className="size-4 text-muted-foreground" />
<span>Bookmarks</span>
</ClickableEventTitle>
<div className="flex flex-col gap-1.5 text-xs">
{(eventPointers.length > 0 || addressPointers.length > 0) && (
<div className="flex items-center gap-1.5">
<FileText className="size-3.5 text-muted-foreground" />
<span>
{eventPointers.length + addressPointers.length} events
</span>
</div>
)}
{urls.length > 0 && (
<div className="flex items-center gap-1.5">
<Link className="size-3.5 text-muted-foreground" />
<span>{urls.length} links</span>
</div>
)}
</div>
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<Bookmark className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Bookmarks</span>
</div>
{(eventPointers.length > 0 || addressPointers.length > 0) && (
<EventRefListFull
eventPointers={eventPointers}
addressPointers={addressPointers}
label="Bookmarked Events"
icon={<FileText className="size-5" />}
/>
)}
{urls.length > 0 && <UrlListFull urls={urls} label="Bookmarked Links" />}
{eventPointers.length === 0 &&
addressPointers.length === 0 &&
urls.length === 0 && (
<div className="text-sm text-muted-foreground italic">
Empty bookmark list
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Bookmark className="size-4 text-muted-foreground" />
<span>{title}</span>
</ClickableEventTitle>
{totalItems === 0 ? (
<div className="text-xs text-muted-foreground italic">
Empty bookmark set
</div>
) : (
<div className="flex flex-col gap-1 text-xs">
{(eventPointers.length > 0 || addressPointers.length > 0) && (
<div className="flex items-center gap-1.5">
<FileText className="size-3.5 text-muted-foreground" />
<span>
{eventPointers.length + addressPointers.length} events
</span>
</div>
)}
{urls.length > 0 && (
<div className="flex items-center gap-1.5">
<Link className="size-3.5 text-muted-foreground" />
<span>{urls.length} links</span>
</div>
)}
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{image && (
<img
src={image}
alt={title}
className="w-full max-w-md h-32 object-cover rounded-lg"
/>
)}
<div className="flex items-center gap-2">
<Bookmark className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">{title}</span>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{(eventPointers.length > 0 || addressPointers.length > 0) && (
<EventRefListFull
eventPointers={eventPointers}
addressPointers={addressPointers}
label="Bookmarked Events"
icon={<FileText className="size-5" />}
/>
)}
{urls.length > 0 && <UrlListFull urls={urls} label="Bookmarked Links" />}
{eventPointers.length === 0 &&
addressPointers.length === 0 &&
urls.length === 0 && (
<div className="text-sm text-muted-foreground italic">
Empty bookmark set
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">No channels</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<MessageCircle className="size-4 text-muted-foreground" />
<span>Public Channels</span>
</ClickableEventTitle>
<div className="flex items-center gap-1.5 text-xs">
<Hash className="size-3.5 text-muted-foreground" />
<span>{channels.length} channels</span>
</div>
</div>
</BaseEventContainer>
);
}
/**
* Kind 10005 Detail View - Full channel list
*/
export function ChannelListDetailRenderer({ event }: { event: NostrEvent }) {
const channels = getChannelPointers(event);
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<MessageCircle className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Public Channels</span>
</div>
{channels.length > 0 ? (
<EventRefListFull
eventPointers={channels}
label="Channels"
icon={<Hash className="size-5" />}
/>
) : (
<div className="text-sm text-muted-foreground italic">No channels</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No communities
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Users2 className="size-4 text-muted-foreground" />
<span>Communities</span>
</ClickableEventTitle>
<div className="flex items-center gap-1.5 text-xs">
<Globe className="size-3.5 text-muted-foreground" />
<span>{communities.length} communities</span>
</div>
</div>
</BaseEventContainer>
);
}
/**
* Kind 10004 Detail View - Full community list
*/
export function CommunityListDetailRenderer({ event }: { event: NostrEvent }) {
const communities = getCommunityPointers(event);
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<Users2 className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Communities</span>
</div>
{communities.length > 0 ? (
<EventRefListFull
addressPointers={communities}
label="Member Of"
icon={<Globe className="size-5" />}
/>
) : (
<div className="text-sm text-muted-foreground italic">
No communities
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
{icon}
<span>{title}</span>
</ClickableEventTitle>
{totalItems === 0 ? (
<div className="text-xs text-muted-foreground italic">
Empty {contentLabel.toLowerCase()} set
</div>
) : (
<div className="flex items-center gap-1.5 text-xs">
<FileText className="size-3.5 text-muted-foreground" />
<span>
{totalItems} {contentLabel.toLowerCase()}
</span>
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{image && (
<img
src={image}
alt={title}
className="w-full max-w-md h-32 object-cover rounded-lg"
/>
)}
<div className="flex items-center gap-2">
{icon}
<span className="text-lg font-semibold">{title}</span>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<EventRefListFull
eventPointers={eventPointers}
addressPointers={addressPointers}
label={contentLabel}
icon={<FileText className="size-5" />}
/>
</div>
);
}
/**
* Kind 30004 Renderer - Article Curation Set (Feed View)
* NIP-51 curated collection of articles
*/
export function ArticleCurationSetRenderer({ event }: BaseEventProps) {
return (
<GenericCurationSetRenderer
event={event}
icon={<Library className="size-4 text-muted-foreground" />}
contentLabel="Articles"
/>
);
}
/**
* Kind 30004 Detail View
*/
export function ArticleCurationSetDetailRenderer({
event,
}: {
event: NostrEvent;
}) {
return (
<GenericCurationSetDetailRenderer
event={event}
icon={<Library className="size-6 text-muted-foreground" />}
contentLabel="Articles"
/>
);
}
/**
* Kind 30005 Renderer - Video Curation Set (Feed View)
* NIP-51 curated collection of videos
*/
export function VideoCurationSetRenderer({ event }: BaseEventProps) {
return (
<GenericCurationSetRenderer
event={event}
icon={<Video className="size-4 text-muted-foreground" />}
contentLabel="Videos"
/>
);
}
/**
* Kind 30005 Detail View
*/
export function VideoCurationSetDetailRenderer({
event,
}: {
event: NostrEvent;
}) {
return (
<GenericCurationSetDetailRenderer
event={event}
icon={<Video className="size-6 text-muted-foreground" />}
contentLabel="Videos"
/>
);
}
/**
* Kind 30006 Renderer - Picture Curation Set (Feed View)
* NIP-51 curated collection of pictures
*/
export function PictureCurationSetRenderer({ event }: BaseEventProps) {
return (
<GenericCurationSetRenderer
event={event}
icon={<Image className="size-4 text-muted-foreground" />}
contentLabel="Pictures"
/>
);
}
/**
* Kind 30006 Detail View
*/
export function PictureCurationSetDetailRenderer({
event,
}: {
event: NostrEvent;
}) {
return (
<GenericCurationSetDetailRenderer
event={event}
icon={<Image className="size-6 text-muted-foreground" />}
contentLabel="Pictures"
/>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No emojis configured
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Smile className="size-4 text-muted-foreground" />
<span>Emoji Preferences</span>
</ClickableEventTitle>
{emojis.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center">
{previewEmojis.map((emoji) => (
<CustomEmoji
key={emoji.shortcode}
shortcode={emoji.shortcode}
url={emoji.url}
size="md"
/>
))}
{remainingCount > 0 && (
<span className="text-xs text-muted-foreground">
+{remainingCount} more
</span>
)}
</div>
)}
{emojiSets.length > 0 && (
<div className="text-xs text-muted-foreground">
+ {emojiSets.length} emoji sets
</div>
)}
<div className="text-xs text-muted-foreground">
{emojis.length} emoji{emojis.length !== 1 ? "s" : ""}
</div>
</div>
</BaseEventContainer>
);
}
/**
* Kind 10030 Detail View - Full emoji list
*/
export function EmojiListDetailRenderer({ event }: { event: NostrEvent }) {
const emojis = getEmojiTags(event);
const emojiSets = getEmojiSetPointers(event);
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<Smile className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Emoji Preferences</span>
</div>
{emojis.length > 0 && (
<div className="flex flex-col gap-2">
<span className="font-semibold">Custom Emojis ({emojis.length})</span>
<div className="flex flex-wrap gap-2">
{emojis.map((emoji) => (
<div
key={emoji.shortcode}
className="flex items-center gap-1.5 px-2 py-1 bg-muted rounded"
>
<CustomEmoji
shortcode={emoji.shortcode}
url={emoji.url}
size="md"
/>
<span className="text-xs text-muted-foreground">
:{emoji.shortcode}:
</span>
</div>
))}
</div>
</div>
)}
{emojiSets.length > 0 && (
<EventRefListFull
addressPointers={emojiSets}
label="Emoji Sets"
icon={<Smile className="size-5" />}
/>
)}
{emojis.length === 0 && emojiSets.length === 0 && (
<div className="text-sm text-muted-foreground italic">
No emojis configured
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Users className="size-4 text-muted-foreground" />
<span>{title}</span>
</ClickableEventTitle>
{pubkeys.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
Empty follow set
</div>
) : (
<PubkeyListPreview
pubkeys={pubkeys}
previewLimit={3}
label="people"
/>
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{image && (
<img
src={image}
alt={title}
className="w-full max-w-md h-32 object-cover rounded-lg"
/>
)}
<div className="flex items-center gap-2">
<Users className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">{title}</span>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<PubkeyListFull pubkeys={pubkeys} label="People" />
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No interests configured
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Sparkles className="size-4 text-muted-foreground" />
<span>Interests</span>
</ClickableEventTitle>
{hashtags.length > 0 && (
<HashtagListPreview hashtags={hashtags} previewLimit={8} />
)}
{interestSets.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>+ {interestSets.length} interest sets</span>
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* Kind 10015 Detail View - Full interest list
*/
export function InterestListDetailRenderer({ event }: { event: NostrEvent }) {
const hashtags = getTagValues(event, "t");
const interestSets = getAddressPointers(event);
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<Sparkles className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Interests</span>
</div>
{hashtags.length > 0 && (
<HashtagListFull hashtags={hashtags} label="Topics" />
)}
{interestSets.length > 0 && (
<EventRefListFull
addressPointers={interestSets}
label="Interest Sets"
icon={<Sparkles className="size-5" />}
/>
)}
{hashtags.length === 0 && interestSets.length === 0 && (
<div className="text-sm text-muted-foreground italic">
No interests configured
</div>
)}
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Sparkles className="size-4 text-muted-foreground" />
<span>{title}</span>
</ClickableEventTitle>
{hashtags.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
Empty interest set
</div>
) : (
<HashtagListPreview hashtags={hashtags} previewLimit={8} />
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{image && (
<img
src={image}
alt={title}
className="w-full max-w-md h-32 object-cover rounded-lg"
/>
)}
<div className="flex items-center gap-2">
<Sparkles className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">{title}</span>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<HashtagListFull hashtags={hashtags} label="Topics" />
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<VolumeX className="size-4 text-muted-foreground" />
<span>Kind Mute</span>
</ClickableEventTitle>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Muting kind:</span>
<KindBadge kind={kindNumber} variant="compact" />
</div>
{pubkeys.length > 0 && (
<PubkeyListPreview
pubkeys={pubkeys}
previewLimit={3}
label="authors muted"
icon={<Users className="size-4 text-muted-foreground" />}
/>
)}
{pubkeys.length === 0 && (
<div className="text-xs text-muted-foreground italic">
Muting all authors for this kind
</div>
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<VolumeX className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Kind Mute Set</span>
</div>
<div className="flex flex-col gap-2">
<span className="font-semibold">Target Kind</span>
<div className="flex items-center gap-2">
<KindBadge kind={kindNumber} showName showKindNumber />
</div>
</div>
{pubkeys.length > 0 ? (
<PubkeyListFull
pubkeys={pubkeys}
label="Muted Authors"
icon={<Users className="size-5 text-muted-foreground" />}
/>
) : (
<div className="text-sm text-muted-foreground">
All authors are muted for this kind
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No media creators followed
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Video className="size-4 text-muted-foreground" />
<span>Media Follows</span>
</ClickableEventTitle>
<PubkeyListPreview
pubkeys={pubkeys}
previewLimit={3}
label="creators"
icon={<Users className="size-4 text-muted-foreground" />}
/>
</div>
</BaseEventContainer>
);
}
/**
* Kind 10020 Detail View - Full media follow list
*/
export function MediaFollowListDetailRenderer({
event,
}: {
event: NostrEvent;
}) {
const pubkeys = getTagValues(event, "p");
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<Video className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Media Follows</span>
</div>
<PubkeyListFull
pubkeys={pubkeys}
label="Creators"
icon={<Users className="size-5" />}
/>
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
Empty mute list
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<VolumeX className="size-4 text-muted-foreground" />
<span>Mute List</span>
</ClickableEventTitle>
<div className="flex flex-col gap-1.5 text-xs">
{mutedPubkeys.length > 0 && (
<div className="flex items-center gap-1.5">
<Users className="size-3.5 text-muted-foreground" />
<span>{mutedPubkeys.length} people</span>
</div>
)}
{mutedHashtags.length > 0 && (
<div className="flex items-center gap-1.5">
<Hash className="size-3.5 text-muted-foreground" />
<span>{mutedHashtags.length} topics</span>
</div>
)}
{mutedWords.length > 0 && (
<div className="flex items-center gap-1.5">
<Type className="size-3.5 text-muted-foreground" />
<span>{mutedWords.length} words</span>
</div>
)}
{mutedThreads.length > 0 && (
<div className="flex items-center gap-1.5">
<FileText className="size-3.5 text-muted-foreground" />
<span>{mutedThreads.length} threads</span>
</div>
)}
</div>
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<VolumeX className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Mute List</span>
</div>
{mutedPubkeys.length > 0 && (
<PubkeyListFull
pubkeys={mutedPubkeys}
label="Muted People"
icon={<Users className="size-5" />}
/>
)}
{mutedHashtags.length > 0 && (
<HashtagListFull hashtags={mutedHashtags} label="Muted Topics" />
)}
{mutedWords.length > 0 && <WordListFull words={mutedWords} />}
{mutedThreads.length > 0 && (
<EventRefListFull
eventPointers={mutedThreads}
label="Muted Threads"
icon={<FileText className="size-5" />}
/>
)}
{mutedPubkeys.length === 0 &&
mutedHashtags.length === 0 &&
mutedWords.length === 0 &&
mutedThreads.length === 0 && (
<div className="text-sm text-muted-foreground italic">
Empty mute list
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No pinned items
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Pin className="size-4 text-muted-foreground" />
<span>Pinned</span>
</ClickableEventTitle>
<div className="flex items-center gap-1.5 text-xs">
<FileText className="size-3.5 text-muted-foreground" />
<span>{totalItems} pinned items</span>
</div>
</div>
</BaseEventContainer>
);
}
/**
* Kind 10001 Detail View - Full pin list
*/
export function PinListDetailRenderer({ event }: { event: NostrEvent }) {
const eventPointers = getEventPointers(event);
const addressPointers = getAddressPointers(event);
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<Pin className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Pinned Items</span>
</div>
{(eventPointers.length > 0 || addressPointers.length > 0) && (
<EventRefListFull
eventPointers={eventPointers}
addressPointers={addressPointers}
label="Pinned"
icon={<FileText className="size-5" />}
/>
)}
{eventPointers.length === 0 && addressPointers.length === 0 && (
<div className="text-sm text-muted-foreground italic">
No pinned items
</div>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Package className="size-4 text-muted-foreground" />
<span>{title}</span>
</ClickableEventTitle>
{pubkeys.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
Empty starter pack
</div>
) : (
<PubkeyListPreview
pubkeys={pubkeys}
previewLimit={5}
label="recommended follows"
/>
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{image && (
<img
src={image}
alt={title}
className="w-full max-w-md h-32 object-cover rounded-lg"
/>
)}
<div className="flex items-center gap-2">
<Package className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">{title}</span>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<PubkeyListFull
pubkeys={pubkeys}
label="Recommended Follows"
icon={<Users className="size-5" />}
/>
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<Video className="size-4 text-muted-foreground" />
<span>{title}</span>
</ClickableEventTitle>
{pubkeys.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
Empty media starter pack
</div>
) : (
<PubkeyListPreview
pubkeys={pubkeys}
previewLimit={5}
label="media creators"
/>
)}
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex flex-col gap-2">
{image && (
<img
src={image}
alt={title}
className="w-full max-w-md h-32 object-cover rounded-lg"
/>
)}
<div className="flex items-center gap-2">
<Video className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">{title}</span>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<PubkeyListFull
pubkeys={pubkeys}
label="Media Creators"
icon={<Users className="size-5" />}
/>
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No trusted wiki authors
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<BookOpen className="size-4 text-muted-foreground" />
<span>Wiki Authors</span>
</ClickableEventTitle>
<PubkeyListPreview
pubkeys={pubkeys}
previewLimit={3}
label="trusted authors"
icon={<Users className="size-4 text-muted-foreground" />}
/>
</div>
</BaseEventContainer>
);
}
/**
* Kind 10101 Detail View - Full wiki authors list
*/
export function WikiAuthorsDetailRenderer({ event }: { event: NostrEvent }) {
const pubkeys = getTagValues(event, "p");
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<BookOpen className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Trusted Wiki Authors</span>
</div>
<PubkeyListFull
pubkeys={pubkeys}
label="Authors"
icon={<Users className="size-5" />}
/>
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No trusted wiki relays
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<BookOpen className="size-4 text-muted-foreground" />
<span>Wiki Relays</span>
</ClickableEventTitle>
<div className="flex items-center gap-1.5 text-xs">
<Server className="size-3.5 text-muted-foreground" />
<span>{relays.length} trusted relays</span>
</div>
</div>
</BaseEventContainer>
);
}
/**
* Kind 10102 Detail View - Full wiki relays list
*/
export function WikiRelaysDetailRenderer({ event }: { event: NostrEvent }) {
const relays = getRelaysFromList(event, "all");
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<BookOpen className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Trusted Wiki Relays</span>
</div>
{relays.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="font-semibold">Relays ({relays.length})</span>
</div>
<div className="flex flex-col gap-1">
{relays.map((url) => (
<RelayLink
key={url}
url={url}
showInboxOutbox={false}
className="py-0.5"
/>
))}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">
No trusted wiki relays
</div>
)}
</div>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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<number, React.ComponentType<BaseEventProps>> = {
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)
};
/**

View File

@@ -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 (
<div className={cn("text-xs text-muted-foreground italic", className)}>
No {label}
</div>
);
}
return (
<div className={cn("flex items-center gap-1.5 text-xs", className)}>
{icon || <FileText className="size-4 text-muted-foreground" />}
<span>
{total} {label}
</span>
</div>
);
}
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 (
<div
className="flex items-center gap-1.5 text-sm cursor-crosshair hover:bg-muted/50 rounded px-1 py-0.5 transition-colors"
onClick={handleClick}
>
<ExternalLink className="size-3.5 text-muted-foreground" />
<span className="text-accent hover:underline hover:decoration-dotted">
{displayText}
</span>
</div>
);
}
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 (
<div className={cn("text-sm text-muted-foreground italic", className)}>
No {label.toLowerCase()}
</div>
);
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center gap-2">
{icon || <FileText className="size-5 text-muted-foreground" />}
<span className="font-semibold">
{label} ({total})
</span>
</div>
{embedded ? (
<div className="flex flex-col gap-2">
{eventPointers.map((pointer) => (
<EmbeddedEvent
key={pointer.id}
eventId={pointer.id}
className="border border-muted rounded overflow-hidden"
/>
))}
{addressPointers.map((pointer) => (
<EmbeddedEvent
key={`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`}
addressPointer={pointer}
className="border border-muted rounded overflow-hidden"
/>
))}
</div>
) : (
<div className="flex flex-col gap-1">
{eventPointers.map((pointer) => (
<EventRefItem key={pointer.id} eventPointer={pointer} />
))}
{addressPointers.map((pointer) => (
<EventRefItem
key={`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`}
addressPointer={pointer}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className={cn("text-xs text-muted-foreground italic", className)}>
No {label}
</div>
);
}
const previewTags = hashtags.slice(0, previewLimit);
const remaining = hashtags.length - previewTags.length;
return (
<div className={cn("flex flex-col gap-1", className)}>
<div className="flex items-center gap-1.5 text-xs">
<Hash className="size-4 text-muted-foreground" />
<span>
{hashtags.length} {label}
</span>
</div>
<div className="flex flex-wrap gap-1">
{previewTags.map((tag) => (
<Label key={tag}>#{tag}</Label>
))}
{remaining > 0 && (
<span className="text-xs text-muted-foreground">
+{remaining} more
</span>
)}
</div>
</div>
);
}
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 (
<div className={cn("text-sm text-muted-foreground italic", className)}>
No {label.toLowerCase()}
</div>
);
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center gap-2">
<Hash className="size-5 text-muted-foreground" />
<span className="font-semibold">
{label} ({hashtags.length})
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{hashtags.map((tag) => (
<Label key={tag} size="md">
#{tag}
</Label>
))}
</div>
</div>
);
}

View File

@@ -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 (
<div className={cn("text-xs text-muted-foreground italic", className)}>
No {label}
</div>
);
}
const previewPubkeys =
previewLimit > 0 ? validPubkeys.slice(0, previewLimit) : [];
const remaining = validPubkeys.length - previewPubkeys.length;
return (
<div className={cn("flex flex-col gap-1", className)}>
<div className="flex items-center gap-1.5 text-xs">
{icon || <Users className="size-4 text-muted-foreground" />}
<span>
{validPubkeys.length} {label}
</span>
</div>
{previewPubkeys.length > 0 && (
<div className="flex flex-wrap gap-1 text-xs">
{previewPubkeys.map((pubkey) => (
<UserName key={pubkey} pubkey={pubkey} className="text-xs" />
))}
{remaining > 0 && (
<span className="text-muted-foreground">+{remaining} more</span>
)}
</div>
)}
</div>
);
}
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 (
<div className={cn("text-sm text-muted-foreground italic", className)}>
No {label.toLowerCase()}
</div>
);
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center gap-2">
{icon || <Users className="size-5" />}
<span className="font-semibold">
{label} ({validPubkeys.length})
</span>
</div>
<div className="flex flex-col gap-1">
{validPubkeys.map((pubkey) => (
<div key={pubkey} className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground"></span>
<UserName pubkey={pubkey} />
</div>
))}
</div>
</div>
);
}

View File

@@ -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 (
<div className={cn("flex flex-col gap-1", className)}>
<div className="flex items-center gap-1.5 text-xs">
<Link className="size-4 text-muted-foreground" />
<span>
{urls.length} {label}
</span>
</div>
<div className="flex flex-col gap-0.5">
{previewUrls.map((url) => (
<UrlItem key={url} url={url} compact />
))}
{remaining > 0 && (
<span className="text-xs text-muted-foreground">
+{remaining} more
</span>
)}
</div>
</div>
);
}
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 (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-1.5 text-accent hover:underline hover:decoration-dotted truncate",
compact ? "text-xs" : "text-sm",
)}
>
<ExternalLink
className={cn(compact ? "size-3" : "size-3.5", "flex-shrink-0")}
/>
<span className="truncate">{displayUrl}</span>
</a>
);
}
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 (
<div className={cn("text-sm text-muted-foreground italic", className)}>
No links
</div>
);
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center gap-2">
<Link className="size-5" />
<span className="font-semibold">
{label} ({urls.length})
</span>
</div>
<div className="flex flex-col gap-1">
{urls.map((url) => (
<UrlItem key={url} url={url} />
))}
</div>
</div>
);
}

View File

@@ -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 (
<div className={cn("flex flex-col gap-1", className)}>
<div className="flex items-center gap-1.5 text-xs">
<Type className="size-4 text-muted-foreground" />
<span>
{words.length} muted {label}
</span>
</div>
<div className="flex flex-wrap gap-1">
{previewWords.map((word) => (
<Label
key={word}
className="border-destructive/50 text-destructive font-mono"
>
{word}
</Label>
))}
{remaining > 0 && (
<span className="text-xs text-muted-foreground">
+{remaining} more
</span>
)}
</div>
</div>
);
}
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 (
<div className={cn("text-sm text-muted-foreground italic", className)}>
No muted words
</div>
);
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center gap-2">
<Type className="size-5" />
<span className="font-semibold">
{label} ({words.length})
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{words.map((word) => (
<Label
key={word}
size="md"
className="border-destructive/50 text-destructive font-mono"
>
{word}
</Label>
))}
</div>
</div>
);
}

View File

@@ -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";