Files
eranos/src/lib/extraKinds.ts
T
lemon d1017697a4 Add NIP-75 community fundraising goals
Implement zap goals (kind 9041) linked to communities via a-tag.
Includes goal creation dialog, progress tracking from zap receipts,
recipient profile/lightning address display, community link, and
members-only filtering. Goals appear in community detail Fundraising
tab, activity feed, and main feed via NoteCard.
2026-05-02 23:39:07 -07:00

626 lines
19 KiB
TypeScript

import type { FeedSettings } from '@/contexts/AppContext';
import type { ComponentType } from 'react';
import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert, Zap } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
/** A sub-kind that lives under a parent ExtraKindDef. */
export interface SubKindDef {
kind: number;
/** Key in FeedSettings that controls visibility within the parent page. */
showKey: keyof FeedSettings;
/** Key in FeedSettings that controls inclusion in mixed feeds. */
feedKey: keyof FeedSettings;
/** Additional kind numbers to include alongside `kind` when the feed toggle is on. */
extraFeedKinds?: number[];
/** Human-readable label. */
label: string;
/** Short description. */
description: string;
/** Whether this kind is addressable (30000-39999). */
addressable?: boolean;
}
/** An external site where users can create or participate in a specific kind. */
export interface ExtraKindSite {
/** Full URL to the site. */
url: string;
/** Display name override. Defaults to the first segment of the hostname, capitalized. */
name?: string;
}
/** Section labels for grouping extra kinds in settings UI. */
export type ExtraKindSection = 'feed' | 'media' | 'social' | 'development' | 'whimsy';
/** Display labels for each section. */
export const SECTION_LABELS: Record<ExtraKindSection, string> = {
feed: 'Feed',
media: 'Media',
social: 'Social',
development: 'Development',
whimsy: 'Whimsy',
};
/** Ordered list of sections for the "Other Stuff" settings UI. */
export const SECTION_ORDER: ExtraKindSection[] = ['media', 'social', 'development', 'whimsy'];
/** Metadata for an extra (non-kind-1) content type. */
export interface ExtraKindDef {
kind: number;
/** Unique identifier for this content type. Used in sidebar ordering and icon maps. */
id: string;
/** Key in FeedSettings that controls sidebar visibility (omit for feed-only items). */
showKey?: keyof FeedSettings;
/** Key in FeedSettings that controls inclusion in mixed feeds (only for entries without subKinds). */
feedKey?: keyof FeedSettings;
/** Additional kind numbers to include alongside `kind` when the feed toggle is on. */
extraFeedKinds?: number[];
/** Human-readable label. */
label: string;
/** Short description. */
description: string;
/** Route path (without leading slash). Omit for feed-only items with no dedicated page. */
route?: string;
/** Whether this kind is addressable (30000-39999). */
addressable: boolean;
/** Optional sub-kinds that break this entry into granular options. */
subKinds?: SubKindDef[];
/** Section grouping for the settings UI. */
section: ExtraKindSection;
/** If true, this entry only has a feed toggle (no sidebar toggle). */
feedOnly?: boolean;
/** Longer, whimsical blurb shown in the info modal. */
blurb?: string;
/** External sites where users can create or participate in this kind of content. */
sites?: ExtraKindSite[];
}
/** All supported extra content kinds, ordered by section (feed → media → social → whimsy). */
export const EXTRA_KINDS: ExtraKindDef[] = [
// Feed (core content types — feed toggle only, no sidebar page)
{
kind: 1,
id: 'posts',
feedKey: 'feedIncludePosts',
label: 'Posts',
description: 'Short text notes',
addressable: false,
section: 'feed',
feedOnly: true,
},
{
kind: 1111,
id: 'comments',
feedKey: 'feedIncludeComments',
label: 'Comments',
description: 'NIP-22 comments on posts and external content',
addressable: false,
section: 'feed',
feedOnly: true,
},
{
kind: 6,
id: 'reposts',
feedKey: 'feedIncludeReposts',
label: 'Reposted Notes',
description: 'Shared posts from others',
addressable: false,
section: 'feed',
feedOnly: true,
},
{
kind: 16,
id: 'generic-reposts',
feedKey: 'feedIncludeGenericReposts',
label: 'Reposted Other Content',
description: 'Shared non-text-note posts from others',
addressable: false,
section: 'feed',
feedOnly: true,
},
{
kind: 30023,
id: 'articles',
showKey: 'showArticles',
feedKey: 'feedIncludeArticles',
label: 'Articles',
description: 'Long-form blog posts',
route: 'articles',
addressable: true,
section: 'feed',
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
},
// Media
{
kind: 20,
id: 'photos',
showKey: 'showPhotos',
feedKey: 'feedIncludePhotos',
label: 'Photos',
description: 'Picture-first posts (NIP-68)',
route: 'photos',
addressable: false,
section: 'media',
blurb: 'Instagram-style photo posts. Share images with captions and tags.',
sites: [{ url: 'https://nostr.build', name: 'nostr.build' }],
},
{
kind: 21,
id: 'videos',
showKey: 'showVideos',
label: 'Videos',
description: 'Video posts (NIP-71 kinds 21 & 22) and live streams',
route: 'videos',
addressable: false,
section: 'media',
blurb: 'Watch and discover videos and live streams in a YouTube/Twitch-style interface.',
sites: [{ url: 'https://zap.stream', name: 'zap.stream' }, { url: 'https://vidstr.shakespeare.wtf', name: 'Vidstr' }],
subKinds: [
{
kind: 21,
showKey: 'showVideos',
feedKey: 'feedIncludeNormalVideos',
label: 'Videos',
description: 'Normal videos (NIP-71 kind 21)',
addressable: false,
},
{
kind: 22,
showKey: 'showVideos',
feedKey: 'feedIncludeShortVideos',
label: 'Short Videos',
description: 'Short vertical videos (NIP-71 kind 22)',
addressable: false,
},
],
},
{
kind: 1222,
id: 'voice',
feedKey: 'feedIncludeVoiceMessages',
label: 'Voice Messages',
description: 'Short audio voice messages (NIP-A0)',
addressable: false,
section: 'media',
feedOnly: true,
blurb: 'Record and share short voice messages, up to 60 seconds long.',
},
{
kind: 34236,
id: 'vines',
showKey: 'showVines',
feedKey: 'feedIncludeVines',
label: 'Divines',
description: 'Short-form videos',
route: 'vines',
addressable: true,
section: 'media',
blurb: 'Short video clips. Record and share from a dedicated app.',
sites: [{ url: 'https://divine.video' }],
},
{
kind: 36787,
id: 'music',
showKey: 'showMusic',
label: 'Music',
description: 'Music tracks and playlists',
route: 'music',
addressable: true,
section: 'media',
blurb: 'Discover and listen to music tracks and playlists shared on Nostr. Upload music and create playlists from a dedicated music app.',
sites: [{ url: 'https://nodecast.xyz' }],
subKinds: [
{
kind: 36787,
showKey: 'showMusic',
feedKey: 'feedIncludeMusicTracks',
label: 'Tracks',
description: 'Music tracks (kind 36787)',
addressable: true,
},
{
kind: 34139,
showKey: 'showMusic',
feedKey: 'feedIncludeMusicPlaylists',
label: 'Playlists',
description: 'Music playlists (kind 34139)',
addressable: true,
},
],
},
{
kind: 30054,
id: 'podcasts',
showKey: 'showPodcasts',
label: 'Podcasts',
description: 'Podcast episodes and trailers',
route: 'podcasts',
addressable: true,
section: 'media',
blurb: 'Listen to podcast episodes and trailers shared on Nostr.',
subKinds: [
{
kind: 30054,
showKey: 'showPodcasts',
feedKey: 'feedIncludePodcastEpisodes',
label: 'Episodes',
description: 'Podcast episodes (kind 30054)',
addressable: true,
},
{
kind: 30055,
showKey: 'showPodcasts',
feedKey: 'feedIncludePodcastTrailers',
label: 'Trailers',
description: 'Podcast trailers (kind 30055)',
addressable: true,
},
],
},
// Social
{
kind: 30315,
id: 'statuses',
showKey: 'showUserStatuses',
label: 'User Statuses',
description: 'Live statuses on profiles and posts (NIP-38)',
addressable: true,
section: 'social',
feedOnly: true,
blurb: 'See what people are up to — statuses appear next to names on posts and on profile pages.',
},
{
kind: 31923,
id: 'events',
showKey: 'showEvents',
feedKey: 'feedIncludeEvents',
extraFeedKinds: [31922],
label: 'Events',
description: 'Calendar events and meetups (NIP-52)',
route: 'events',
addressable: true,
section: 'social',
blurb: 'Events and meetups on Nostr. RSVP and see who else is going. Create and manage events on Plektos.',
sites: [{ url: 'https://plektos.app', name: 'Plektos' }],
},
{
kind: 1063,
id: 'webxdc',
showKey: 'showWebxdc',
feedKey: 'feedIncludeWebxdc',
label: 'Webxdc',
description: 'Sandboxed HTML5 apps shared over Nostr',
route: 'webxdc',
addressable: false,
section: 'social',
blurb: 'Webxdc apps are sandboxed HTML5 mini-apps (.xdc files) shared over Nostr. Play games, run tools, and collaborate with others.',
sites: [{ url: 'https://webxdc.org', name: 'webxdc.org' }],
},
{
kind: 1068,
id: 'polls',
showKey: 'showPolls',
feedKey: 'feedIncludePolls',
label: 'Polls',
description: 'Community polls and votes',
route: 'polls',
addressable: false,
section: 'social',
blurb: 'Ask a question, let people vote. Create polls from a polling app.',
sites: [{ url: 'https://pollerama.fun' }],
},
{
kind: 39089,
id: 'packs',
showKey: 'showPacks',
feedKey: 'feedIncludePacks',
label: 'Follow Packs',
description: 'Curated follow recommendations',
route: 'packs',
addressable: true,
section: 'social',
blurb: 'Curated lists of people to follow. Browse or create your own.',
sites: [{ url: 'https://following.space', name: 'following.space' }, { url: 'https://following.party', name: 'following.party' }],
},
{
kind: 34550,
id: 'communities',
showKey: 'showCommunities',
feedKey: 'feedIncludeCommunities',
extraFeedKinds: [9041],
label: 'Communities',
description: 'Hierarchical communities with ranked membership (NIP-72)',
route: 'communities',
addressable: true,
section: 'social',
blurb: 'Hierarchical communities on Nostr with ranked membership, badge-based authority chains, and moderation. Founded and managed by community creators.',
},
{
kind: 62,
id: 'vanish',
feedKey: 'feedIncludeVanish',
label: 'Requests to Vanish',
description: 'NIP-62 account erasure announcements',
addressable: false,
section: 'social',
feedOnly: true,
blurb: 'When someone permanently leaves Nostr, their Request to Vanish event signals the end of their identity on the network.',
},
// Whimsy
{
kind: 3367,
id: 'colors',
showKey: 'showColors',
feedKey: 'feedIncludeColors',
label: 'Color Moments',
description: 'Color moment palettes',
route: 'colors',
addressable: false,
section: 'whimsy',
blurb: 'Share your mood as a color palette. Pick colors that match the moment.',
sites: [{ url: 'https://espy.you' }],
},
{
kind: 37381,
id: 'decks',
showKey: 'showDecks',
feedKey: 'feedIncludeDecks',
label: 'Magic Decks',
description: 'Magic: The Gathering deck lists',
route: 'decks',
addressable: true,
section: 'whimsy',
blurb: 'Magic: The Gathering deck lists. Build and share decks with other players.',
sites: [{ url: 'https://surveil.cards' }],
},
{
kind: 30030,
id: 'emojis',
showKey: 'showEmojiPacks',
feedKey: 'feedIncludeEmojiPacks',
label: 'Emoji Packs',
description: 'Custom emoji collections (NIP-30)',
route: 'emojis',
addressable: true,
section: 'social',
blurb: 'Custom emoji packs for reactions, posts, and profiles. Browse, collect, and use custom emojis from the Nostr community.',
},
{
kind: 30009,
id: 'badges',
showKey: 'showBadges',
label: 'Badges',
description: 'Badges and awards (NIP-58)',
route: 'badges',
addressable: true,
section: 'whimsy',
blurb: 'Discover badges created on Nostr. Badge issuers award them for recognition, participation, or appreciation.',
subKinds: [
{
kind: 30009,
showKey: 'showBadgeDefinitions',
feedKey: 'feedIncludeBadgeDefinitions',
label: 'Badge Definitions',
description: 'Badge definitions (kind 30009)',
addressable: true,
},
{
kind: 10008,
showKey: 'showProfileBadges',
feedKey: 'feedIncludeProfileBadges',
label: 'Profile Badges',
description: 'Accepted profile badges (kind 10008)',
extraFeedKinds: [30008], // legacy kind for backwards compatibility
},
],
},
{
kind: 37516,
id: 'treasures',
showKey: 'showTreasures',
label: 'Treasures',
description: 'Treasures & found logs',
route: 'treasures',
addressable: true,
section: 'whimsy',
blurb: 'Real-world treasure hunting. Hide treasures outside, find others, and log your discoveries.',
sites: [{ url: 'https://treasures.to' }],
subKinds: [
{
kind: 37516,
showKey: 'showTreasureGeocaches',
feedKey: 'feedIncludeTreasureGeocaches',
label: 'Treasures',
description: 'Treasure listings',
addressable: true,
},
{
kind: 7516,
showKey: 'showTreasureFoundLogs',
feedKey: 'feedIncludeTreasureFoundLogs',
label: 'Found Logs',
description: 'Treasure found logs',
addressable: false,
},
],
},
// Development
{
kind: 30617,
id: 'development',
showKey: 'showDevelopment',
feedKey: 'feedIncludeDevelopment',
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 30063, 31990],
label: 'Development',
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
route: 'development',
addressable: true,
section: 'development',
blurb: 'Nostr-native git repositories, patches, pull requests, nsite deployments, custom NIPs, and published applications.',
sites: [{ url: 'https://gitworkshop.dev', name: 'Gitworkshop' }, { url: 'https://nostrhub.io', name: 'NostrHub' }],
},
];
/** Lookup an ExtraKindDef by its `id` field. */
export function getExtraKindDef(id: string): ExtraKindDef | undefined {
return EXTRA_KINDS.find((d) => d.id === id);
}
/** Entries rendered in the "Notes" section (Posts, Reposts, Articles). */
export const FEED_KINDS: ExtraKindDef[] = EXTRA_KINDS.filter((def) => def.section === 'feed');
/** Return the kind numbers the user has opted to include in mixed feeds. */
export function getEnabledFeedKinds(feedSettings: FeedSettings): number[] {
const kinds: number[] = [];
for (const def of EXTRA_KINDS) {
if (def.subKinds) {
for (const sub of def.subKinds) {
if (feedSettings[sub.feedKey]) {
kinds.push(sub.kind);
if (sub.extraFeedKinds) {
kinds.push(...sub.extraFeedKinds);
}
}
}
} else if (def.feedKey && feedSettings[def.feedKey]) {
kinds.push(def.kind);
if (def.extraFeedKinds) {
kinds.push(...def.extraFeedKinds);
}
}
}
return kinds;
}
/** Return the kinds enabled for a specific extra-kind page (respecting sub-kind toggles). */
export function getPageKinds(def: ExtraKindDef, feedSettings: FeedSettings): number[] {
if (!def.subKinds) return [def.kind];
return def.subKinds
.filter((sub) => feedSettings[sub.showKey])
.flatMap((sub) => sub.extraFeedKinds ? [sub.kind, ...sub.extraFeedKinds] : [sub.kind]);
}
/**
* Specific labels for kinds that don't have their own top-level ExtraKindDef.
* These are kinds buried in `extraFeedKinds` arrays or otherwise needing
* a label more specific than their parent category.
*/
const KIND_SPECIFIC_LABELS: Record<number, string> = {
6: 'repost',
7: 'reaction',
16: 'repost',
1617: 'patch',
1618: 'patch comment',
15128: 'nsite',
35128: 'nsite',
30008: 'profile badges',
30817: 'repository issue',
32267: 'Zapstore app',
31990: 'app',
30063: 'Zapstore release',
3063: 'Zapstore asset',
36639: 'action',
};
/**
* Specific icons for kinds that need a different icon than their parent category.
*/
const KIND_SPECIFIC_ICONS: Partial<Record<number, ComponentType<{ className?: string }>>> = {
6: RepostIcon,
16: RepostIcon,
1617: GitPullRequestArrow,
1618: MessageSquareMore,
15128: Globe,
35128: Globe,
30817: CircleAlert,
36639: Zap,
};
/**
* Get a human-readable label for a specific kind number.
* Resolution order: subKind label → KIND_SPECIFIC_LABELS → direct def label.
* Returns undefined if the kind is completely unknown.
*/
export function getKindLabel(kind: number): string | undefined {
// Check subKinds first (they carry their own label)
for (const def of EXTRA_KINDS) {
const sub = def.subKinds?.find((s) => s.kind === kind);
if (sub) return sub.label.toLowerCase();
}
// Check specific overrides (extraFeedKinds items, etc.)
if (KIND_SPECIFIC_LABELS[kind]) return KIND_SPECIFIC_LABELS[kind];
// Check top-level def
const def = EXTRA_KINDS.find((d) => d.kind === kind);
if (def) return def.label.toLowerCase();
return undefined;
}
/** Map from kind number to ExtraKindDef id, for quick icon lookup. */
const KIND_TO_ID = new Map<number, string>();
for (const def of EXTRA_KINDS) {
KIND_TO_ID.set(def.kind, def.id);
if (def.subKinds) {
for (const sub of def.subKinds) {
if (!KIND_TO_ID.has(sub.kind)) {
KIND_TO_ID.set(sub.kind, def.id);
}
if (sub.extraFeedKinds) {
for (const k of sub.extraFeedKinds) {
if (!KIND_TO_ID.has(k)) {
KIND_TO_ID.set(k, def.id);
}
}
}
}
}
if (def.extraFeedKinds) {
for (const k of def.extraFeedKinds) {
if (!KIND_TO_ID.has(k)) {
KIND_TO_ID.set(k, def.id);
}
}
}
}
/** Get the sidebar/content-type ID for a given kind number, if any. */
export function getKindId(kind: number): string | undefined {
return KIND_TO_ID.get(kind);
}
/**
* Get the icon component for a given kind number.
* Checks KIND_SPECIFIC_ICONS first, then falls back to the parent def's icon via CONTENT_KIND_ICONS.
* Returns undefined if no icon mapping exists (caller provides fallback).
*/
export function getKindIcon(kind: number): ComponentType<{ className?: string }> | undefined {
if (KIND_SPECIFIC_ICONS[kind]) return KIND_SPECIFIC_ICONS[kind];
const id = KIND_TO_ID.get(kind);
if (!id) return undefined;
return CONTENT_KIND_ICONS[id];
}
/** Return all extra kind numbers (regardless of settings). */
export function getAllExtraKindNumbers(): number[] {
const kinds: number[] = [];
for (const def of EXTRA_KINDS) {
if (def.subKinds) {
for (const sub of def.subKinds) {
if (!kinds.includes(sub.kind)) kinds.push(sub.kind);
if (sub.extraFeedKinds) {
for (const k of sub.extraFeedKinds) {
if (!kinds.includes(k)) kinds.push(k);
}
}
}
} else {
kinds.push(def.kind);
}
}
return kinds;
}