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} + )} +
+ + {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 && ( + {title} + )} +
+ {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} + )} +
+ + {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} + )} +
+ + {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 +
+ +
+ Target Kind +
+ +
+
+ + {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 ( + +
+ + + + } + /> +
+
+ ); +} + +/** + * Kind 10020 Detail View - Full media follow list + */ +export function MediaFollowListDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { + const pubkeys = getTagValues(event, "p"); + + return ( +
+
+
+ + } + /> +
+ ); +} 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 ( +
+
+ + Pinned Items +
+ + {(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 && ( + {title} + )} +
+ + {title} +
+ {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 ( + +
+ + + + {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";