From 55537dc78ce7241e131da7261960d1210f6ef526 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 3 Feb 2026 07:42:39 +0100 Subject: [PATCH] bug-fixes --- docker-compose.yml | 2 +- public/healthz.json | 4 +- src/lib/modules/comments/Comment.svelte | 33 +++ src/lib/modules/comments/CommentThread.svelte | 74 ++--- src/lib/modules/feed/FeedPage.svelte | 264 +++++++++++++----- src/lib/modules/feed/FeedPost.svelte | 36 +++ .../modules/feed/ReplaceableEventCard.svelte | 158 +++++++++++ src/lib/modules/feed/Reply.svelte | 33 +++ src/lib/modules/feed/ZapReceiptReply.svelte | 36 ++- src/lib/modules/threads/ThreadCard.svelte | 33 +++ src/lib/modules/threads/ThreadView.svelte | 33 +++ src/lib/services/nostr/config.ts | 15 +- src/lib/services/nostr/nostr-client.ts | 238 ++++++++-------- src/lib/services/nostr/relay-manager.ts | 2 +- src/lib/types/kind-lookup.ts | 113 ++++++++ 15 files changed, 834 insertions(+), 240 deletions(-) create mode 100644 src/lib/modules/feed/ReplaceableEventCard.svelte create mode 100644 src/lib/types/kind-lookup.ts diff --git a/docker-compose.yml b/docker-compose.yml index 5e4a7c6..1793caa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: . args: - VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.wine,wss://nostr.sovbit.host" + VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.wine,wss://nostr.sovbit.host,wss://orly-relay.imwald.eu" VITE_ZAP_THRESHOLD: "1" VITE_THREAD_TIMEOUT_DAYS: "30" VITE_PWA_ENABLED: "true" diff --git a/public/healthz.json b/public/healthz.json index f26830a..32db4f4 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-02T15:23:47.430Z", + "buildTime": "2026-02-03T06:34:16.073Z", "gitCommit": "unknown", - "timestamp": 1770045827431 + "timestamp": 1770100456074 } \ No newline at end of file diff --git a/src/lib/modules/comments/Comment.svelte b/src/lib/modules/comments/Comment.svelte index b08c4d1..29ae6e7 100644 --- a/src/lib/modules/comments/Comment.svelte +++ b/src/lib/modules/comments/Comment.svelte @@ -3,6 +3,7 @@ import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { getKindInfo } from '../../types/kind-lookup.js'; interface Props { comment: NostrEvent; @@ -106,6 +107,11 @@ {expanded ? 'Show less' : 'Show more'} {/if} + +
+ {getKindInfo(comment.kind).number} + {getKindInfo(comment.kind).description} +
diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index 1e3f8b6..811fb53 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -71,31 +71,31 @@ hasNewComments = false; const allCommentIds = new Set(comments.map(c => c.id)); - if (allCommentIds.size > 0) { + if (allCommentIds.size > 0) { // Fetch comments that reference any comment we have (replies to replies) - const replyToCommentsFilters = [ - { - kinds: [1111], - '#K': ['11'], // Comments on kind 11 threads - '#E': Array.from(allCommentIds) // Comments that reference any of our comments - } - ]; - - const replyToComments = await nostrClient.fetchEvents( - replyToCommentsFilters, - relays, - { useCache: true, cacheResults: true } - ); - - // Add new comments that are replies to our comments - for (const reply of replyToComments) { - if (!allCommentIds.has(reply.id)) { - comments.push(reply); + const replyToCommentsFilters = [ + { + kinds: [1111], + '#K': ['11'], // Comments on kind 11 threads + '#E': Array.from(allCommentIds) // Comments that reference any of our comments + } + ]; + + const replyToComments = await nostrClient.fetchEvents( + replyToCommentsFilters, + relays, + { useCache: true, cacheResults: true } + ); + + // Add new comments that are replies to our comments + for (const reply of replyToComments) { + if (!allCommentIds.has(reply.id)) { + comments.push(reply); hasNewComments = true; - } } } - + } + // Also fetch missing parent comments that are referenced but not loaded const missingReplyIds = new Set(); for (const comment of comments) { @@ -109,21 +109,21 @@ } if (missingReplyIds.size > 0) { - const replyComments = await nostrClient.fetchEvents( - [{ kinds: [1111], ids: Array.from(missingReplyIds) }], - relays, - { useCache: true, cacheResults: true } - ); - + const replyComments = await nostrClient.fetchEvents( + [{ kinds: [1111], ids: Array.from(missingReplyIds) }], + relays, + { useCache: true, cacheResults: true } + ); + // Add new parent comments - for (const reply of replyComments) { + for (const reply of replyComments) { const exists = comments.some(c => c.id === reply.id); if (!exists) { - comments.push(reply); + comments.push(reply); hasNewComments = true; - } } } + } } } @@ -214,7 +214,7 @@ eventMap.set(item.event.id, item); allEventIds.add(item.event.id); } - + // Second pass: determine parent-child relationships for (const item of items) { // Check if this is a reply @@ -224,12 +224,12 @@ if (parentId) { // Check if parent is the thread or another comment/zap we have if (parentId === threadId || allEventIds.has(parentId)) { - // This is a reply - if (!replyMap.has(parentId)) { - replyMap.set(parentId, []); - } - replyMap.get(parentId)!.push(item.event.id); - } else { + // This is a reply + if (!replyMap.has(parentId)) { + replyMap.set(parentId, []); + } + replyMap.get(parentId)!.push(item.event.id); + } else { // Parent not found - treat as root item (might be a missing parent) rootItems.push(item); } diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index b576a0e..0c423ea 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -1,13 +1,16 @@
@@ -350,7 +462,7 @@ {#if loading}

Loading feed...

- {:else if posts.length === 0} + {:else if posts.length === 0 && replaceableEvents.length === 0}

No posts yet. Be the first to post!

{:else} {#if newPostsCount > 0} @@ -364,21 +476,27 @@
{/if}
- {#each getFilteredPosts() as post, index (post.id)} - {@const parentId = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]} + {#each getAllFeedItems() as item (item.id)} + {#if item.type === 'post'} + {@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]} {@const parentEvent = parentId ? posts.find(p => p.id === parentId) : undefined} -
- +
+ +
+ {:else if item.type === 'replaceable'} +
+
+ {/if} {/each}
{#if loadingMore}

Loading more...

{/if} - {#if !hasMore && getFilteredPosts().length > 0} + {#if !hasMore && getAllFeedItems().length > 0}

No more posts

{/if} - {#if showOPsOnly && getFilteredPosts().length === 0 && posts.length > 0} + {#if showOPsOnly && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0}

No original posts found. Try unchecking "Show OPs only".

{/if} {/if} diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 774693c..2e4cac2 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -9,6 +9,7 @@ import { relayManager } from '../../services/nostr/relay-manager.js'; import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { getKindInfo } from '../../types/kind-lookup.js'; interface Props { post: NostrEvent; @@ -182,6 +183,11 @@ {expanded ? 'Show less' : 'Show more'} {/if} + +
+ {getKindInfo(post.kind).number} + {getKindInfo(post.kind).description} +
diff --git a/src/lib/modules/feed/ReplaceableEventCard.svelte b/src/lib/modules/feed/ReplaceableEventCard.svelte new file mode 100644 index 0000000..172ebe6 --- /dev/null +++ b/src/lib/modules/feed/ReplaceableEventCard.svelte @@ -0,0 +1,158 @@ + + +
+
+
+ + {getRelativeTime()} + {#if getClientName()} + via {getClientName()} + {/if} +
+
+ +
+ {#if getDTag()} +
+ d-tag: + {getDTag()} +
+ {/if} + + {#if event.content} +
+ {event.content.slice(0, 200)}{event.content.length > 200 ? '...' : ''} +
+ {/if} +
+ +
+ {#if getWikistrUrl()} + + View on wikistr + + + + + {/if} +
+ +
+ {getKindInfo(event.kind).number} + {getKindInfo(event.kind).description} +
+
+ + diff --git a/src/lib/modules/feed/Reply.svelte b/src/lib/modules/feed/Reply.svelte index 0ed175d..7647abd 100644 --- a/src/lib/modules/feed/Reply.svelte +++ b/src/lib/modules/feed/Reply.svelte @@ -5,6 +5,7 @@ import ZapButton from '../zaps/ZapButton.svelte'; import ZapReceipt from '../zaps/ZapReceipt.svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { getKindInfo } from '../../types/kind-lookup.js'; interface Props { reply: NostrEvent; @@ -119,6 +120,11 @@ {expanded ? 'Show less' : 'Show more'} {/if} + +
+ {getKindInfo(reply.kind).number} + {getKindInfo(reply.kind).description} +
diff --git a/src/lib/modules/feed/ZapReceiptReply.svelte b/src/lib/modules/feed/ZapReceiptReply.svelte index 4a01522..5fcf6b0 100644 --- a/src/lib/modules/feed/ZapReceiptReply.svelte +++ b/src/lib/modules/feed/ZapReceiptReply.svelte @@ -2,6 +2,7 @@ import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { getKindInfo } from '../../types/kind-lookup.js'; interface Props { zapReceipt: NostrEvent; // Kind 9735 zap receipt @@ -90,8 +91,9 @@ {getAmount().toLocaleString()} sats {getRelativeTime()} {#if getZappedPubkey()} + {@const zappedPubkey = getZappedPubkey()!} - to + to {/if}
@@ -122,6 +124,11 @@ {expanded ? 'Show less' : 'Show more'} {/if} + +
+ {getKindInfo(zapReceipt.kind).number} + {getKindInfo(zapReceipt.kind).description} +
diff --git a/src/lib/modules/threads/ThreadCard.svelte b/src/lib/modules/threads/ThreadCard.svelte index 66a83b3..b44da99 100644 --- a/src/lib/modules/threads/ThreadCard.svelte +++ b/src/lib/modules/threads/ThreadCard.svelte @@ -4,6 +4,7 @@ import { relayManager } from '../../services/nostr/relay-manager.js'; import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { getKindInfo } from '../../types/kind-lookup.js'; interface Props { thread: NostrEvent; @@ -250,11 +251,17 @@ {expanded ? 'Show less' : 'Show more'} {/if} + +
+ {getKindInfo(thread.kind).number} + {getKindInfo(thread.kind).description} +
diff --git a/src/lib/modules/threads/ThreadView.svelte b/src/lib/modules/threads/ThreadView.svelte index 0036e0d..3b2d12f 100644 --- a/src/lib/modules/threads/ThreadView.svelte +++ b/src/lib/modules/threads/ThreadView.svelte @@ -10,6 +10,7 @@ import { relayManager } from '../../services/nostr/relay-manager.js'; import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; + import { getKindInfo } from '../../types/kind-lookup.js'; interface Props { threadId: string; @@ -151,6 +152,11 @@
+ +
+ {getKindInfo(thread.kind).number} + {getKindInfo(thread.kind).description} +
{:else}

Thread not found

@@ -160,6 +166,7 @@ .thread-view { max-width: var(--content-width); margin: 0 auto; + position: relative; } .thread-content { @@ -203,4 +210,30 @@ border: none; cursor: pointer; } + + .kind-badge { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.125rem; + font-size: 0.625rem; + line-height: 1; + color: var(--fog-text-light, #9ca3af); + } + + :global(.dark) .kind-badge { + color: var(--fog-dark-text-light, #6b7280); + } + + .kind-number { + font-weight: 600; + } + + .kind-description { + font-size: 0.5rem; + opacity: 0.8; + } diff --git a/src/lib/services/nostr/config.ts b/src/lib/services/nostr/config.ts index 7a38956..4be7aae 100644 --- a/src/lib/services/nostr/config.ts +++ b/src/lib/services/nostr/config.ts @@ -8,7 +8,8 @@ const DEFAULT_RELAYS = [ 'wss://nostr21.com', 'wss://nostr.land', 'wss://nostr.wine', - 'wss://nostr.sovbit.host' + 'wss://nostr.sovbit.host', + 'wss://orly-relay.imwald.eu' ]; const PROFILE_RELAYS = [ @@ -17,12 +18,20 @@ const PROFILE_RELAYS = [ 'wss://profiles.nostr1.com' ]; +const THREAD_PUBLISH_RELAYS = [ + 'wss://thecitadel.nostr1.com' +]; + +const RELAY_TIMEOUT = 10000; + export interface NostrConfig { defaultRelays: string[]; profileRelays: string[]; zapThreshold: number; threadTimeoutDays: number; pwaEnabled: boolean; + threadPublishRelays: string[]; + relayTimeout: number; } function parseRelays(envVar: string | undefined, fallback: string[]): string[] { @@ -52,7 +61,9 @@ export function getConfig(): NostrConfig { profileRelays: PROFILE_RELAYS, zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0), threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30), - pwaEnabled: parseBoolEnv(import.meta.env.VITE_PWA_ENABLED, true) + pwaEnabled: parseBoolEnv(import.meta.env.VITE_PWA_ENABLED, true), + threadPublishRelays: THREAD_PUBLISH_RELAYS, + relayTimeout: RELAY_TIMEOUT }; } diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 17985e5..822c7bc 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -89,11 +89,33 @@ class NostrClient { async removeRelay(url: string): Promise { const relay = this.relays.get(url); if (relay) { - relay.close(); + try { + relay.close(); + } catch (error) { + // Ignore errors when closing + } this.relays.delete(url); } } + /** + * Check if a relay is still connected and remove it if closed + */ + private checkAndCleanupRelay(relayUrl: string): boolean { + const relay = this.relays.get(relayUrl); + if (!relay) return false; + + // Check relay status: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED + const status = (relay as any).status; + if (status === 3) { + // Relay is closed, remove it + this.relays.delete(relayUrl); + return false; + } + + return true; + } + /** * Add event to cache */ @@ -348,7 +370,7 @@ class NostrClient { async fetchEvents( filters: Filter[], relays: string[], - options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void } + options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout?: number } ): Promise { const { useCache = true, cacheResults = true, onUpdate } = options || {}; @@ -393,7 +415,7 @@ class NostrClient { // Fetch fresh data in background if (cacheResults) { setTimeout(() => { - this.fetchFromRelays(filters, relays, { cacheResults, onUpdate }).catch((error) => { + this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout }).catch((error) => { console.error('Error fetching fresh events from relays:', error); }); }, 0); @@ -404,149 +426,119 @@ class NostrClient { } // Fetch from relays - return this.fetchFromRelays(filters, relays, { cacheResults, onUpdate }); + return this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout: options?.timeout }); } + /** - * Fetch events from relays + * Fetch events from relays - one request per relay with all filters, sent in parallel */ private async fetchFromRelays( filters: Filter[], relays: string[], - options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void } + options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout?: number } ): Promise { - return new Promise((resolve, reject) => { - const events: Map = new Map(); - const relayCount = new Set(); - const connectedRelays = new Set(); - let resolved = false; - let eoseTimeout: ReturnType | null = null; - let timeoutId: ReturnType | null = null; - let subId: string | null = null; // Declare subId at function scope - const client = this; - - const finish = (eventArray: NostrEvent[]) => { - if (resolved) return; - resolved = true; - - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; + const timeout = options.timeout || config.relayTimeout; // Default 10 seconds + const client = this; + + // Filter to only connected relays + let availableRelays = relays.filter(url => this.relays.has(url)); + + if (availableRelays.length === 0) { + // Try to connect to relays if none are connected + await Promise.allSettled(relays.map(url => this.addRelay(url).catch(() => null))); + availableRelays = relays.filter(url => this.relays.has(url)); + if (availableRelays.length === 0) { + return []; + } + } + + // Create one subscription per relay with all filters, sent in parallel + const events: Map = new Map(); + const relayPromises = availableRelays.map((relayUrl) => { + return new Promise((resolve) => { + const relay = client.relays.get(relayUrl); + if (!relay) { + resolve(); + return; } - if (eoseTimeout) { - clearTimeout(eoseTimeout); - eoseTimeout = null; + + // Check if relay connection is still open, remove if closed + if (!client.checkAndCleanupRelay(relayUrl)) { + resolve(); + return; } - if (subId) { + const subId = `sub_${client.nextSubId++}_${Date.now()}`; + let resolved = false; + let timeoutId: ReturnType | null = null; + + const finish = () => { + if (resolved) return; + resolved = true; + if (timeoutId) clearTimeout(timeoutId); client.unsubscribe(subId); - subId = null; - } + resolve(); + }; - const eventArrayValues = Array.from(eventArray); - const filtered = client.filterEvents(eventArrayValues); - - // Cache results - if (options.cacheResults && filtered.length > 0) { - cacheEvents(filtered).catch((error) => { - console.error('Error caching events:', error); + try { + const sub = relay.subscribe(filters, { + onevent(event: NostrEvent) { + if (!client.relays.has(relayUrl)) return; + if (client.shouldHideEvent(event)) return; + events.set(event.id, event); + client.addToCache(event); + }, + oneose() { + if (!resolved) { + finish(); + } + } }); - } - if (options.onUpdate) { - options.onUpdate(filtered); - } + client.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub }); - resolve(filtered); - }; - - const onEvent = (event: NostrEvent, relayUrl: string) => { - // Skip hidden events - if (client.shouldHideEvent(event)) return; - - events.set(event.id, event); - relayCount.add(relayUrl); - connectedRelays.add(relayUrl); - }; - - const onEose = (relayUrl: string) => { - relayCount.add(relayUrl); - connectedRelays.add(relayUrl); - - // If we got EOSE from at least one relay, wait a bit for more events, then finish - if (eoseTimeout) { - clearTimeout(eoseTimeout); - } - eoseTimeout = setTimeout(() => { - if (!resolved && subId) { - finish(Array.from(events.values())); - } - }, 2000); // Wait 2 seconds after first EOSE - }; - - // Ensure we have at least some connected relays - let availableRelays = relays.filter(url => { - const relay = this.relays.get(url); - // Check if relay exists - return relay !== undefined; - }); - - // If no relays connected, try to connect - if (availableRelays.length === 0 && relays.length > 0) { - Promise.all(relays.map(url => { - return this.addRelay(url).catch(err => { - console.warn(`Failed to connect to relay ${url}:`, err); - return null; - }); - })).then(() => { - // Re-check available relays after connection attempts - availableRelays = relays.filter(url => { - const relay = this.relays.get(url); - return relay !== undefined; - }); - - if (availableRelays.length === 0) { - // Still no relays, return empty after a short delay - console.warn('No relays available for fetchEvents'); - setTimeout(() => finish([]), 100); - return; - } - - // Subscribe to available relays - subId = this.subscribe(filters, availableRelays, onEvent, onEose); - - // Timeout after 30 seconds + // Timeout after specified duration timeoutId = setTimeout(() => { if (!resolved) { - console.warn('fetchEvents timeout after 30 seconds'); - finish(Array.from(events.values())); + finish(); } - }, 30000); - }).catch(() => { - // If connection fails completely, return empty - finish([]); - }); - return; - } - - // Subscribe to events - if (availableRelays.length > 0) { - subId = this.subscribe(filters, availableRelays, onEvent, onEose); - - // Timeout after 30 seconds - timeoutId = setTimeout(() => { - if (!resolved) { - console.warn('fetchEvents timeout after 30 seconds'); - finish(Array.from(events.values())); + }, timeout); + } catch (error: any) { + // Handle errors during subscription creation + if (error && (error.message?.includes('closed') || error.message?.includes('SendingOnClosedConnection'))) { + // Relay closed, remove it + client.relays.delete(relayUrl); + } else { + console.warn(`Error subscribing to relay ${relayUrl}:`, error); } - }, 30000); - } else { - // No relays available, return empty immediately - finish([]); - } + finish(); + } + }); }); + + // Wait for all relay requests to complete (or timeout) + await Promise.allSettled(relayPromises); + + const eventArray = Array.from(events.values()); + const filtered = this.filterEvents(eventArray); + + // Cache results in background + if (options.cacheResults && filtered.length > 0) { + cacheEvents(filtered).catch((error) => { + console.error('Error caching events:', error); + }); + } + + // Call onUpdate callback + if (options.onUpdate) { + options.onUpdate(filtered); + } + + return filtered; } + /** * Get event by ID */ diff --git a/src/lib/services/nostr/relay-manager.ts b/src/lib/services/nostr/relay-manager.ts index c2f5016..d1174f4 100644 --- a/src/lib/services/nostr/relay-manager.ts +++ b/src/lib/services/nostr/relay-manager.ts @@ -170,7 +170,7 @@ class RelayManager { getThreadPublishRelays(): string[] { return this.getPublishRelays([ ...config.defaultRelays, - 'wss://thecitadel.nostr1.com' + ...config.threadPublishRelays ]); } diff --git a/src/lib/types/kind-lookup.ts b/src/lib/types/kind-lookup.ts new file mode 100644 index 0000000..cc83f23 --- /dev/null +++ b/src/lib/types/kind-lookup.ts @@ -0,0 +1,113 @@ +/** + * Kind number to description lookup + * Based on NIPs and common Nostr event kinds + */ + +export interface KindInfo { + number: number; + description: string; + showInFeed?: boolean; // Whether this kind should be displayed on the Feed page + isReplaceable?: boolean; // Whether this is a replaceable event (requires d-tag) + isSecondaryKind?: boolean; // Whether this is a secondary kind (used to display the main kind) +} + +export const KIND_LOOKUP: Record = { + // Core kinds + 0: { number: 0, description: 'Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + 1: { number: 1, description: 'Short Text Note', showInFeed: true, isReplaceable: false }, + 3: { number: 3, description: 'Contacts', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + 24: { number: 4, description: 'Public Message', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, + 5: { number: 5, description: 'Event Deletion', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + 7: { number: 7, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, + + // Replaceable events + 30023: { number: 30023, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, + 30041: { number: 30041, description: 'Publication Content', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, + 30040: { number: 30040, description: 'Curated Publication or E-Book', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, + 30817: { number: 30817, description: 'Wiki Page (Markdown)', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, + 30818: { number: 30818, description: 'Wiki Page (Asciidoc)', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, + + // Threads and comments + 11: { number: 11, description: 'Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + 1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true }, + + // Media + 20: { number: 20, description: 'Picture Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, + 21: { number: 21, description: 'Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, + 22: { number: 22, description: 'Short Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, + 1222: { number: 23, description: 'Voice Note (Yak)', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, + 1244: { number: 24, description: 'Voice Reply (Yak Back)', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, + + // Polls + 1068: { number: 1068, description: 'Poll', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, + 1018: { number: 1018, description: 'Poll Response', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, + + // Labels + 1985: { number: 1985, description: 'Label', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // User status + 30315: { number: 30315, description: 'User Status', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, + + // Zaps + 9735: { number: 9735, description: 'Zap Receipt', showInFeed: true, isReplaceable: false, isSecondaryKind: true }, + + // Relay lists + 10002: { number: 10002, description: 'Relay List Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // Blocked relays + 10006: { number: 10006, description: 'Blocked Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // Favorite relays + 10012: { number: 10012, description: 'Favorite Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // Interest lists + 10015: { number: 10015, description: 'Interest List', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // Local relays + 10432: { number: 10432, description: 'Local Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // Mute lists + 10000: { number: 10000, description: 'Mute List', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // Pin lists + 10001: { number: 10001, description: 'Pin List', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // Payment addresses + 10133: { number: 10133, description: 'Payment Addresses', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, + + // RSS feeds + 10895: { number: 10895, description: 'RSS Feed', showInFeed: false, isReplaceable: false } + +}; + +/** + * Get kind info for a given kind number + */ +export function getKindInfo(kind: number): KindInfo { + return KIND_LOOKUP[kind] || { number: kind, description: `Kind ${kind}`, showInFeed: false }; +} + +/** + * Get kind description for a given kind number + */ +export function getKindDescription(kind: number): string { + return getKindInfo(kind).description; +} + +/** + * Get all kinds that should be displayed in the Feed + */ +export function getFeedKinds(): number[] { + return Object.values(KIND_LOOKUP) + .filter(kind => kind.showInFeed === true) + .map(kind => kind.number); +} + +/** + * Get all replaceable event kinds (that require d-tags) + */ +export function getReplaceableKinds(): number[] { + return Object.values(KIND_LOOKUP) + .filter(kind => kind.isReplaceable === true) + .map(kind => kind.number); +}