Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
55537dc78c
  1. 2
      docker-compose.yml
  2. 4
      public/healthz.json
  3. 33
      src/lib/modules/comments/Comment.svelte
  4. 254
      src/lib/modules/feed/FeedPage.svelte
  5. 36
      src/lib/modules/feed/FeedPost.svelte
  6. 158
      src/lib/modules/feed/ReplaceableEventCard.svelte
  7. 33
      src/lib/modules/feed/Reply.svelte
  8. 36
      src/lib/modules/feed/ZapReceiptReply.svelte
  9. 33
      src/lib/modules/threads/ThreadCard.svelte
  10. 33
      src/lib/modules/threads/ThreadView.svelte
  11. 15
      src/lib/services/nostr/config.ts
  12. 206
      src/lib/services/nostr/nostr-client.ts
  13. 2
      src/lib/services/nostr/relay-manager.ts
  14. 113
      src/lib/types/kind-lookup.ts

2
docker-compose.yml

@ -5,7 +5,7 @@ services:
build: build:
context: . context: .
args: 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_ZAP_THRESHOLD: "1"
VITE_THREAD_TIMEOUT_DAYS: "30" VITE_THREAD_TIMEOUT_DAYS: "30"
VITE_PWA_ENABLED: "true" VITE_PWA_ENABLED: "true"

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.0", "version": "0.1.0",
"buildTime": "2026-02-02T15:23:47.430Z", "buildTime": "2026-02-03T06:34:16.073Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770045827431 "timestamp": 1770100456074
} }

33
src/lib/modules/comments/Comment.svelte

@ -3,6 +3,7 @@
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props { interface Props {
comment: NostrEvent; comment: NostrEvent;
@ -106,6 +107,11 @@
{expanded ? 'Show less' : 'Show more'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
{/if} {/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(comment.kind).number}</span>
<span class="kind-description">{getKindInfo(comment.kind).description}</span>
</div>
</article> </article>
<style> <style>
@ -115,6 +121,7 @@
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
position: relative;
} }
:global(.dark) .comment { :global(.dark) .comment {
@ -145,4 +152,30 @@
border: none; border: none;
cursor: pointer; 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;
}
</style> </style>

254
src/lib/modules/feed/FeedPage.svelte

@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import ReplaceableEventCard from './ReplaceableEventCard.svelte';
import CreateFeedForm from './CreateFeedForm.svelte'; import CreateFeedForm from './CreateFeedForm.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js'; import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js';
let posts = $state<NostrEvent[]>([]); let posts = $state<NostrEvent[]>([]);
let replaceableEvents = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null); let replyingTo = $state<NostrEvent | null>(null);
let showNewPostForm = $state(false); let showNewPostForm = $state(false);
@ -105,35 +108,81 @@
} }
try { try {
const config = nostrClient.getConfig();
const oldestTimestamp = posts.length > 0 const oldestTimestamp = posts.length > 0
? Math.min(...posts.map(p => p.created_at)) ? Math.min(...posts.map(p => p.created_at))
: undefined; : undefined;
const filters = [ const relays = relayManager.getFeedReadRelays();
{ const feedKinds = getFeedKinds();
kinds: [1], const replaceableKinds = getReplaceableKinds();
limit: 50,
// Phase 1: Fetch all feed kinds - one request per relay, sent in parallel
// Update cache in background (10 second timeout), view updates when cache is done
const feedFilter = feedKinds.length > 0 ? [{
kinds: feedKinds,
limit: 100,
...(oldestTimestamp ? { until: oldestTimestamp } : {}) ...(oldestTimestamp ? { until: oldestTimestamp } : {})
}] : [];
let allFeedEvents: NostrEvent[] = [];
let cacheUpdatePromise: Promise<void> | null = null;
if (feedFilter.length > 0) {
// Get cached events first for immediate display
const cachedEvents = await nostrClient.fetchEvents(
feedFilter,
relays,
{ useCache: true, cacheResults: false }
);
// Process cached events immediately
const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1);
const replaceable = cachedEvents.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd')
);
const otherFeedEvents = cachedEvents.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
if (reset) {
posts = sortPosts([...regularPosts, ...otherFeedEvents]);
replaceableEvents = replaceable.sort((a, b) => b.created_at - a.created_at);
lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null;
} }
];
const relays = relayManager.getFeedReadRelays(); // Fetch fresh data in background with 10 second timeout, update cache
const events = await nostrClient.fetchEvents( cacheUpdatePromise = nostrClient.fetchEvents(
filters, feedFilter,
relays, relays,
{ useCache: true, cacheResults: true, onUpdate: (updated) => { {
const sorted = sortPosts(updated); useCache: false,
cacheResults: true,
timeout: 10000,
onUpdate: (updated) => {
const updatedRegularPosts = updated.filter((e: NostrEvent) => e.kind === 1);
const updatedReplaceable = updated.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd')
);
const updatedOtherFeedEvents = updated.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
const sorted = sortPosts([...updatedRegularPosts, ...updatedOtherFeedEvents]);
if (reset) { if (reset) {
posts = sorted; posts = sorted;
lastPostId = sorted.length > 0 ? sorted[0].id : null; replaceableEvents = updatedReplaceable.sort((a, b) => b.created_at - a.created_at);
lastPostId = updatedRegularPosts.length > 0 ? updatedRegularPosts[0].id : null;
} else { } else {
// Merge new posts
const existingIds = new Set(posts.map(p => p.id)); const existingIds = new Set(posts.map(p => p.id));
const newPosts = sorted.filter(e => !existingIds.has(e.id)); const newPosts = sorted.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) { if (newPosts.length > 0) {
// Count new posts that are newer than the last seen post
if (lastPostId) { if (lastPostId) {
const newCount = sorted.filter(e => e.id !== lastPostId && !existingIds.has(e.id)).length; const newCount = sorted.filter(e => e.id !== lastPostId && !existingIds.has(e.id)).length;
if (newCount > 0) { if (newCount > 0) {
@ -142,69 +191,104 @@
} }
posts = sortPosts([...posts, ...newPosts]); posts = sortPosts([...posts, ...newPosts]);
} }
}
}}
);
// Recursively fetch missing parent events to build complete threads const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
const allEventIds = new Set(events.map(e => e.id)); const newReplaceable = updatedReplaceable.filter(e => !existingReplaceableIds.has(e.id));
let missingParentIds = new Set<string>(); if (newReplaceable.length > 0) {
replaceableEvents = [...replaceableEvents, ...newReplaceable].sort((a, b) => b.created_at - a.created_at);
}
}
// Find all missing parents allFeedEvents = updated;
for (const event of events) {
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1];
if (parentId && !allEventIds.has(parentId)) {
missingParentIds.add(parentId);
} }
} }
).then(events => {
allFeedEvents = events;
}).catch(err => {
console.error('Error fetching feed events:', err);
});
}
// Recursively fetch missing parents until we have them all // Wait for Phase 1 cache update to complete before proceeding
while (missingParentIds.size > 0) { if (cacheUpdatePromise) {
try { await cacheUpdatePromise;
const parentEvents = await nostrClient.fetchEvents( }
[{ kinds: [1], ids: Array.from(missingParentIds) }],
relays,
{ useCache: true, cacheResults: true }
);
// Add fetched events // Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events
for (const parentEvent of parentEvents) { // One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
if (!allEventIds.has(parentEvent.id)) { const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id);
events.push(parentEvent); if (displayedEventIds.length > 0) {
allEventIds.add(parentEvent.id); // Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events
const secondaryFilter = [{
kinds: [7, 9735],
'#e': displayedEventIds.slice(0, 100) // Limit to avoid huge requests
}];
// Fetch in background, update cache, view will update automatically via cache
nostrClient.fetchEvents(
secondaryFilter,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000
} }
).catch(err => {
console.error('Error fetching secondary events:', err);
});
} }
// Check if any of the newly fetched parents also have missing parents // Phase 3: Fetch kind 0 profiles for npubs in feed
const newMissingIds = new Set<string>(); // One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
for (const parentEvent of parentEvents) { const uniquePubkeys = new Set<string>();
const replyTag = parentEvent.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); for (const event of [...posts, ...replaceableEvents]) {
const grandParentId = replyTag?.[1]; uniquePubkeys.add(event.pubkey);
if (grandParentId && !allEventIds.has(grandParentId)) {
newMissingIds.add(grandParentId);
} }
if (uniquePubkeys.size > 0) {
const profileFilter = [{
kinds: [0],
authors: Array.from(uniquePubkeys).slice(0, 100) // Limit to avoid huge requests
}];
// Fetch in background, update cache, view will update automatically via cache
nostrClient.fetchEvents(
profileFilter,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000
}
).catch(err => {
console.error('Error fetching profiles:', err);
});
} }
missingParentIds = newMissingIds; // Fetch missing parent events (batch, one request per relay)
} catch (error) { const allEventIds = new Set(posts.map(e => e.id));
console.error('Error fetching missing parent events:', error); const missingParentIds = new Set<string>();
break; // Stop trying if there's an error
for (const event of posts) {
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1];
if (parentId && !allEventIds.has(parentId)) {
missingParentIds.add(parentId);
} }
} }
if (reset) { if (missingParentIds.size > 0) {
posts = sortPosts(events); const parentIdsArray = Array.from(missingParentIds).slice(0, 50);
lastPostId = events.length > 0 ? events[0].id : null; nostrClient.fetchEvents(
newPostsCount = 0; [{ kinds: [1], ids: parentIdsArray }],
} else { relays,
// Merge new posts { useCache: true, cacheResults: true, timeout: 10000 }
const existingIds = new Set(posts.map(p => p.id)); ).catch(err => {
const newPosts = events.filter(e => !existingIds.has(e.id)); console.error('Error fetching parent events:', err);
posts = sortPosts([...posts, ...newPosts]); });
} }
hasMore = events.length >= 50; hasMore = allFeedEvents.length >= 100;
} catch (error) { } catch (error) {
console.error('Error loading feed:', error); console.error('Error loading feed:', error);
} finally { } finally {
@ -312,6 +396,34 @@
if (!showOPsOnly) return posts; if (!showOPsOnly) return posts;
return posts.filter(post => !isReply(post)); return posts.filter(post => !isReply(post));
} }
function getAllFeedItems(): Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> {
const items: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> = [];
// Add filtered posts
const filteredPosts = getFilteredPosts();
for (const post of filteredPosts) {
items.push({
id: post.id,
event: post,
type: 'post',
created_at: post.created_at
});
}
// Add replaceable events
for (const event of replaceableEvents) {
items.push({
id: event.id,
event: event,
type: 'replaceable',
created_at: event.created_at
});
}
// Sort by created_at, newest first
return items.sort((a, b) => b.created_at - a.created_at);
}
</script> </script>
<div class="Feed-feed"> <div class="Feed-feed">
@ -350,7 +462,7 @@
{#if loading} {#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p>
{:else if posts.length === 0} {:else if posts.length === 0 && replaceableEvents.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p>
{:else} {:else}
{#if newPostsCount > 0} {#if newPostsCount > 0}
@ -364,21 +476,27 @@
</div> </div>
{/if} {/if}
<div class="posts-list"> <div class="posts-list">
{#each getFilteredPosts() as post, index (post.id)} {#each getAllFeedItems() as item (item.id)}
{@const parentId = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]} {#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} {@const parentEvent = parentId ? posts.find(p => p.id === parentId) : undefined}
<div data-post-id={post.id} class="post-wrapper"> <div data-post-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>
<FeedPost {post} parentEvent={parentEvent} onReply={handleReply} /> <FeedPost post={item.event} parentEvent={parentEvent} onReply={handleReply} />
</div> </div>
{:else if item.type === 'replaceable'}
<div data-event-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>
<ReplaceableEventCard event={item.event} />
</div>
{/if}
{/each} {/each}
</div> </div>
{#if loadingMore} {#if loadingMore}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p> <p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p>
{/if} {/if}
{#if !hasMore && getFilteredPosts().length > 0} {#if !hasMore && getAllFeedItems().length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p> <p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if} {/if}
{#if showOPsOnly && getFilteredPosts().length === 0 && posts.length > 0} {#if showOPsOnly && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No original posts found. Try unchecking "Show OPs only".</p> <p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No original posts found. Try unchecking "Show OPs only".</p>
{/if} {/if}
{/if} {/if}

36
src/lib/modules/feed/FeedPost.svelte

@ -9,6 +9,7 @@
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props { interface Props {
post: NostrEvent; post: NostrEvent;
@ -182,6 +183,11 @@
{expanded ? 'Show less' : 'Show more'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
{/if} {/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
</article> </article>
<style> <style>
@ -231,4 +237,34 @@
cursor: pointer; 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;
}
.Feed-post {
position: relative;
}
</style> </style>

158
src/lib/modules/feed/ReplaceableEventCard.svelte

@ -0,0 +1,158 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
function getDTag(): string | null {
const dTag = event.tags.find((t) => t[0] === 'd');
return dTag?.[1] || null;
}
function getWikistrUrl(): string | null {
const dTag = getDTag();
if (!dTag) return null;
return `https://wikistr.imwald.eu/${dTag}*${event.pubkey}`;
}
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - event.created_at;
const hours = Math.floor(diff / 3600);
const days = Math.floor(diff / 86400);
const minutes = Math.floor(diff / 60);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
function getClientName(): string | null {
const clientTag = event.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
</script>
<article class="replaceable-event-card">
<div class="card-header flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<ProfileBadge pubkey={event.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
</div>
</div>
<div class="card-content mb-2">
{#if getDTag()}
<div class="d-tag-display mb-2">
<span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text">d-tag:</span>
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light ml-1">{getDTag()}</span>
</div>
{/if}
{#if event.content}
<div class="content-preview text-sm text-fog-text dark:text-fog-dark-text mb-2">
{event.content.slice(0, 200)}{event.content.length > 200 ? '...' : ''}
</div>
{/if}
</div>
<div class="card-actions">
{#if getWikistrUrl()}
<a
href={getWikistrUrl()}
target="_blank"
rel="noopener noreferrer"
class="wikistr-link inline-flex items-center gap-2 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm"
>
<span>View on wikistr</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/if}
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(event.kind).number}</span>
<span class="kind-description">{getKindInfo(event.kind).description}</span>
</div>
</article>
<style>
.replaceable-event-card {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-left: 3px solid var(--fog-accent, #64748b);
}
:global(.dark) .replaceable-event-card {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: var(--fog-dark-accent, #64748b);
}
.card-header {
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .card-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.content-preview {
line-height: 1.6;
}
.wikistr-link {
text-decoration: none;
transition: opacity 0.2s;
}
.wikistr-link:hover {
opacity: 0.9;
}
.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;
}
.replaceable-event-card {
position: relative;
}
</style>

33
src/lib/modules/feed/Reply.svelte

@ -5,6 +5,7 @@
import ZapButton from '../zaps/ZapButton.svelte'; import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte'; import ZapReceipt from '../zaps/ZapReceipt.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props { interface Props {
reply: NostrEvent; reply: NostrEvent;
@ -119,6 +120,11 @@
{expanded ? 'Show less' : 'Show more'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
{/if} {/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(reply.kind).number}</span>
<span class="kind-description">{getKindInfo(reply.kind).description}</span>
</div>
</article> </article>
<style> <style>
@ -129,6 +135,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
border-left: 3px solid var(--fog-accent, #64748b); border-left: 3px solid var(--fog-accent, #64748b);
position: relative;
} }
:global(.dark) .Feed-reply { :global(.dark) .Feed-reply {
@ -178,4 +185,30 @@
border: none; border: none;
cursor: pointer; 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;
}
</style> </style>

36
src/lib/modules/feed/ZapReceiptReply.svelte

@ -2,6 +2,7 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props { interface Props {
zapReceipt: NostrEvent; // Kind 9735 zap receipt zapReceipt: NostrEvent; // Kind 9735 zap receipt
@ -90,8 +91,9 @@
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span> <span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
{#if getZappedPubkey()} {#if getZappedPubkey()}
{@const zappedPubkey = getZappedPubkey()!}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light"> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
to <ProfileBadge pubkey={getZappedPubkey()} /> to <ProfileBadge pubkey={zappedPubkey} />
</span> </span>
{/if} {/if}
</div> </div>
@ -122,6 +124,11 @@
{expanded ? 'Show less' : 'Show more'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
{/if} {/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(zapReceipt.kind).number}</span>
<span class="kind-description">{getKindInfo(zapReceipt.kind).description}</span>
</div>
</article> </article>
<style> <style>
@ -132,6 +139,7 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
border-left: 3px solid #fbbf24; /* Gold/yellow for zaps */ border-left: 3px solid #fbbf24; /* Gold/yellow for zaps */
position: relative;
} }
:global(.dark) .zap-receipt-reply { :global(.dark) .zap-receipt-reply {
@ -172,4 +180,30 @@
border: none; border: none;
cursor: pointer; 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;
}
</style> </style>

33
src/lib/modules/threads/ThreadCard.svelte

@ -4,6 +4,7 @@
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props { interface Props {
thread: NostrEvent; thread: NostrEvent;
@ -250,11 +251,17 @@
{expanded ? 'Show less' : 'Show more'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
{/if} {/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(thread.kind).number}</span>
<span class="kind-description">{getKindInfo(thread.kind).description}</span>
</div>
</article> </article>
<style> <style>
.thread-card { .thread-card {
max-width: var(--content-width); max-width: var(--content-width);
position: relative;
} }
.thread-card a { .thread-card a {
@ -284,4 +291,30 @@
border: none; border: none;
cursor: pointer; 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;
}
</style> </style>

33
src/lib/modules/threads/ThreadView.svelte

@ -10,6 +10,7 @@
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props { interface Props {
threadId: string; threadId: string;
@ -151,6 +152,11 @@
<div class="comments-section"> <div class="comments-section">
<CommentThread threadId={thread.id} /> <CommentThread threadId={thread.id} />
</div> </div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(thread.kind).number}</span>
<span class="kind-description">{getKindInfo(thread.kind).description}</span>
</div>
</article> </article>
{:else} {:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p> <p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
@ -160,6 +166,7 @@
.thread-view { .thread-view {
max-width: var(--content-width); max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
position: relative;
} }
.thread-content { .thread-content {
@ -203,4 +210,30 @@
border: none; border: none;
cursor: pointer; 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;
}
</style> </style>

15
src/lib/services/nostr/config.ts

@ -8,7 +8,8 @@ const DEFAULT_RELAYS = [
'wss://nostr21.com', 'wss://nostr21.com',
'wss://nostr.land', 'wss://nostr.land',
'wss://nostr.wine', 'wss://nostr.wine',
'wss://nostr.sovbit.host' 'wss://nostr.sovbit.host',
'wss://orly-relay.imwald.eu'
]; ];
const PROFILE_RELAYS = [ const PROFILE_RELAYS = [
@ -17,12 +18,20 @@ const PROFILE_RELAYS = [
'wss://profiles.nostr1.com' 'wss://profiles.nostr1.com'
]; ];
const THREAD_PUBLISH_RELAYS = [
'wss://thecitadel.nostr1.com'
];
const RELAY_TIMEOUT = 10000;
export interface NostrConfig { export interface NostrConfig {
defaultRelays: string[]; defaultRelays: string[];
profileRelays: string[]; profileRelays: string[];
zapThreshold: number; zapThreshold: number;
threadTimeoutDays: number; threadTimeoutDays: number;
pwaEnabled: boolean; pwaEnabled: boolean;
threadPublishRelays: string[];
relayTimeout: number;
} }
function parseRelays(envVar: string | undefined, fallback: string[]): string[] { function parseRelays(envVar: string | undefined, fallback: string[]): string[] {
@ -52,7 +61,9 @@ export function getConfig(): NostrConfig {
profileRelays: PROFILE_RELAYS, profileRelays: PROFILE_RELAYS,
zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0), zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0),
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30), 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
}; };
} }

206
src/lib/services/nostr/nostr-client.ts

@ -89,11 +89,33 @@ class NostrClient {
async removeRelay(url: string): Promise<void> { async removeRelay(url: string): Promise<void> {
const relay = this.relays.get(url); const relay = this.relays.get(url);
if (relay) { if (relay) {
try {
relay.close(); relay.close();
} catch (error) {
// Ignore errors when closing
}
this.relays.delete(url); 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 * Add event to cache
*/ */
@ -348,7 +370,7 @@ class NostrClient {
async fetchEvents( async fetchEvents(
filters: Filter[], filters: Filter[],
relays: string[], relays: string[],
options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void } options?: { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout?: number }
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const { useCache = true, cacheResults = true, onUpdate } = options || {}; const { useCache = true, cacheResults = true, onUpdate } = options || {};
@ -393,7 +415,7 @@ class NostrClient {
// Fetch fresh data in background // Fetch fresh data in background
if (cacheResults) { if (cacheResults) {
setTimeout(() => { 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); console.error('Error fetching fresh events from relays:', error);
}); });
}, 0); }, 0);
@ -404,149 +426,119 @@ class NostrClient {
} }
// Fetch from relays // 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( private async fetchFromRelays(
filters: Filter[], filters: Filter[],
relays: string[], relays: string[],
options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void } options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout?: number }
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
return new Promise((resolve, reject) => { const timeout = options.timeout || config.relayTimeout; // Default 10 seconds
const events: Map<string, NostrEvent> = new Map();
const relayCount = new Set<string>();
const connectedRelays = new Set<string>();
let resolved = false;
let eoseTimeout: ReturnType<typeof setTimeout> | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let subId: string | null = null; // Declare subId at function scope
const client = this; const client = this;
const finish = (eventArray: NostrEvent[]) => { // Filter to only connected relays
if (resolved) return; let availableRelays = relays.filter(url => this.relays.has(url));
resolved = true;
if (timeoutId) { if (availableRelays.length === 0) {
clearTimeout(timeoutId); // Try to connect to relays if none are connected
timeoutId = null; await Promise.allSettled(relays.map(url => this.addRelay(url).catch(() => null)));
availableRelays = relays.filter(url => this.relays.has(url));
if (availableRelays.length === 0) {
return [];
} }
if (eoseTimeout) {
clearTimeout(eoseTimeout);
eoseTimeout = null;
} }
if (subId) { // Create one subscription per relay with all filters, sent in parallel
client.unsubscribe(subId); const events: Map<string, NostrEvent> = new Map();
subId = null; const relayPromises = availableRelays.map((relayUrl) => {
return new Promise<void>((resolve) => {
const relay = client.relays.get(relayUrl);
if (!relay) {
resolve();
return;
} }
const eventArrayValues = Array.from(eventArray); // Check if relay connection is still open, remove if closed
const filtered = client.filterEvents(eventArrayValues); if (!client.checkAndCleanupRelay(relayUrl)) {
resolve();
// Cache results return;
if (options.cacheResults && filtered.length > 0) {
cacheEvents(filtered).catch((error) => {
console.error('Error caching events:', error);
});
} }
if (options.onUpdate) { const subId = `sub_${client.nextSubId++}_${Date.now()}`;
options.onUpdate(filtered); let resolved = false;
} let timeoutId: ReturnType<typeof setTimeout> | null = null;
resolve(filtered); const finish = () => {
if (resolved) return;
resolved = true;
if (timeoutId) clearTimeout(timeoutId);
client.unsubscribe(subId);
resolve();
}; };
const onEvent = (event: NostrEvent, relayUrl: string) => { try {
// Skip hidden events const sub = relay.subscribe(filters, {
onevent(event: NostrEvent) {
if (!client.relays.has(relayUrl)) return;
if (client.shouldHideEvent(event)) return; if (client.shouldHideEvent(event)) return;
events.set(event.id, event); events.set(event.id, event);
relayCount.add(relayUrl); client.addToCache(event);
connectedRelays.add(relayUrl); },
}; oneose() {
if (!resolved) {
const onEose = (relayUrl: string) => { finish();
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) { client.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub });
// 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(() => { timeoutId = setTimeout(() => {
if (!resolved) { if (!resolved) {
console.warn('fetchEvents timeout after 30 seconds'); finish();
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); finish();
}).catch(() => {
// If connection fails completely, return empty
finish([]);
});
return;
} }
});
});
// Subscribe to events // Wait for all relay requests to complete (or timeout)
if (availableRelays.length > 0) { await Promise.allSettled(relayPromises);
subId = this.subscribe(filters, availableRelays, onEvent, onEose);
// Timeout after 30 seconds const eventArray = Array.from(events.values());
timeoutId = setTimeout(() => { const filtered = this.filterEvents(eventArray);
if (!resolved) {
console.warn('fetchEvents timeout after 30 seconds'); // Cache results in background
finish(Array.from(events.values())); if (options.cacheResults && filtered.length > 0) {
cacheEvents(filtered).catch((error) => {
console.error('Error caching events:', error);
});
} }
}, 30000);
} else { // Call onUpdate callback
// No relays available, return empty immediately if (options.onUpdate) {
finish([]); options.onUpdate(filtered);
} }
});
return filtered;
} }
/** /**
* Get event by ID * Get event by ID
*/ */

2
src/lib/services/nostr/relay-manager.ts

@ -170,7 +170,7 @@ class RelayManager {
getThreadPublishRelays(): string[] { getThreadPublishRelays(): string[] {
return this.getPublishRelays([ return this.getPublishRelays([
...config.defaultRelays, ...config.defaultRelays,
'wss://thecitadel.nostr1.com' ...config.threadPublishRelays
]); ]);
} }

113
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<number, KindInfo> = {
// 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);
}
Loading…
Cancel
Save