mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
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:
137
src/components/nostr/kinds/BookmarkListRenderer.tsx
Normal file
137
src/components/nostr/kinds/BookmarkListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/components/nostr/kinds/BookmarkSetRenderer.tsx
Normal file
153
src/components/nostr/kinds/BookmarkSetRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/nostr/kinds/ChannelListRenderer.tsx
Normal file
89
src/components/nostr/kinds/ChannelListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/nostr/kinds/CommunityListRenderer.tsx
Normal file
93
src/components/nostr/kinds/CommunityListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
src/components/nostr/kinds/CurationSetRenderer.tsx
Normal file
240
src/components/nostr/kinds/CurationSetRenderer.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
148
src/components/nostr/kinds/EmojiListRenderer.tsx
Normal file
148
src/components/nostr/kinds/EmojiListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/components/nostr/kinds/FollowSetRenderer.tsx
Normal file
81
src/components/nostr/kinds/FollowSetRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/nostr/kinds/InterestListRenderer.tsx
Normal file
178
src/components/nostr/kinds/InterestListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/nostr/kinds/KindMuteSetRenderer.tsx
Normal file
93
src/components/nostr/kinds/KindMuteSetRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/nostr/kinds/MediaFollowListRenderer.tsx
Normal file
74
src/components/nostr/kinds/MediaFollowListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/components/nostr/kinds/MuteListRenderer.tsx
Normal file
150
src/components/nostr/kinds/MuteListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/components/nostr/kinds/PinListRenderer.tsx
Normal file
117
src/components/nostr/kinds/PinListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/components/nostr/kinds/StarterPackRenderer.tsx
Normal file
162
src/components/nostr/kinds/StarterPackRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/components/nostr/kinds/WikiListRenderer.tsx
Normal file
148
src/components/nostr/kinds/WikiListRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
167
src/components/nostr/lists/EventRefList.tsx
Normal file
167
src/components/nostr/lists/EventRefList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/nostr/lists/HashtagListPreview.tsx
Normal file
97
src/components/nostr/lists/HashtagListPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/nostr/lists/PubkeyListPreview.tsx
Normal file
112
src/components/nostr/lists/PubkeyListPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/components/nostr/lists/UrlList.tsx
Normal file
125
src/components/nostr/lists/UrlList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/nostr/lists/WordList.tsx
Normal file
101
src/components/nostr/lists/WordList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/nostr/lists/index.tsx
Normal file
11
src/components/nostr/lists/index.tsx
Normal 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";
|
||||
Reference in New Issue
Block a user