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: @@ -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"

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -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
}

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

@ -3,6 +3,7 @@ @@ -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 @@ @@ -106,6 +107,11 @@
{expanded ? 'Show less' : 'Show more'}
</button>
{/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>
<style>
@ -115,6 +121,7 @@ @@ -115,6 +121,7 @@
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
position: relative;
}
:global(.dark) .comment {
@ -145,4 +152,30 @@ @@ -145,4 +152,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;
}
</style>

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

@ -1,13 +1,16 @@ @@ -1,13 +1,16 @@
<script lang="ts">
import FeedPost from './FeedPost.svelte';
import ReplaceableEventCard from './ReplaceableEventCard.svelte';
import CreateFeedForm from './CreateFeedForm.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js';
let posts = $state<NostrEvent[]>([]);
let replaceableEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
let showNewPostForm = $state(false);
@ -105,35 +108,81 @@ @@ -105,35 +108,81 @@
}
try {
const config = nostrClient.getConfig();
const oldestTimestamp = posts.length > 0
? Math.min(...posts.map(p => p.created_at))
: undefined;
const filters = [
{
kinds: [1],
limit: 50,
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds();
const replaceableKinds = getReplaceableKinds();
// 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 } : {})
}] : [];
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();
const events = await nostrClient.fetchEvents(
filters,
// Fetch fresh data in background with 10 second timeout, update cache
cacheUpdatePromise = nostrClient.fetchEvents(
feedFilter,
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) {
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 {
// Merge new posts
const existingIds = new Set(posts.map(p => p.id));
const newPosts = sorted.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Count new posts that are newer than the last seen post
if (lastPostId) {
const newCount = sorted.filter(e => e.id !== lastPostId && !existingIds.has(e.id)).length;
if (newCount > 0) {
@ -142,69 +191,104 @@ @@ -142,69 +191,104 @@
}
posts = sortPosts([...posts, ...newPosts]);
}
}
}}
);
// Recursively fetch missing parent events to build complete threads
const allEventIds = new Set(events.map(e => e.id));
let missingParentIds = new Set<string>();
const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
const newReplaceable = updatedReplaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
replaceableEvents = [...replaceableEvents, ...newReplaceable].sort((a, b) => b.created_at - a.created_at);
}
}
// Find all missing parents
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);
allFeedEvents = updated;
}
}
).then(events => {
allFeedEvents = events;
}).catch(err => {
console.error('Error fetching feed events:', err);
});
}
// Recursively fetch missing parents until we have them all
while (missingParentIds.size > 0) {
try {
const parentEvents = await nostrClient.fetchEvents(
[{ kinds: [1], ids: Array.from(missingParentIds) }],
relays,
{ useCache: true, cacheResults: true }
);
// Wait for Phase 1 cache update to complete before proceeding
if (cacheUpdatePromise) {
await cacheUpdatePromise;
}
// Add fetched events
for (const parentEvent of parentEvents) {
if (!allEventIds.has(parentEvent.id)) {
events.push(parentEvent);
allEventIds.add(parentEvent.id);
// Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id);
if (displayedEventIds.length > 0) {
// 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
const newMissingIds = new Set<string>();
for (const parentEvent of parentEvents) {
const replyTag = parentEvent.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const grandParentId = replyTag?.[1];
if (grandParentId && !allEventIds.has(grandParentId)) {
newMissingIds.add(grandParentId);
// Phase 3: Fetch kind 0 profiles for npubs in feed
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
const uniquePubkeys = new Set<string>();
for (const event of [...posts, ...replaceableEvents]) {
uniquePubkeys.add(event.pubkey);
}
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;
} catch (error) {
console.error('Error fetching missing parent events:', error);
break; // Stop trying if there's an error
// Fetch missing parent events (batch, one request per relay)
const allEventIds = new Set(posts.map(e => e.id));
const missingParentIds = new Set<string>();
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) {
posts = sortPosts(events);
lastPostId = events.length > 0 ? events[0].id : null;
newPostsCount = 0;
} else {
// Merge new posts
const existingIds = new Set(posts.map(p => p.id));
const newPosts = events.filter(e => !existingIds.has(e.id));
posts = sortPosts([...posts, ...newPosts]);
if (missingParentIds.size > 0) {
const parentIdsArray = Array.from(missingParentIds).slice(0, 50);
nostrClient.fetchEvents(
[{ kinds: [1], ids: parentIdsArray }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
).catch(err => {
console.error('Error fetching parent events:', err);
});
}
hasMore = events.length >= 50;
hasMore = allFeedEvents.length >= 100;
} catch (error) {
console.error('Error loading feed:', error);
} finally {
@ -312,6 +396,34 @@ @@ -312,6 +396,34 @@
if (!showOPsOnly) return posts;
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>
<div class="Feed-feed">
@ -350,7 +462,7 @@ @@ -350,7 +462,7 @@
{#if loading}
<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>
{:else}
{#if newPostsCount > 0}
@ -364,21 +476,27 @@ @@ -364,21 +476,27 @@
</div>
{/if}
<div class="posts-list">
{#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}
<div data-post-id={post.id} class="post-wrapper">
<FeedPost {post} parentEvent={parentEvent} onReply={handleReply} />
<div data-post-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>
<FeedPost post={item.event} parentEvent={parentEvent} onReply={handleReply} />
</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}
</div>
{#if loadingMore}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p>
{/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>
{/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>
{/if}
{/if}

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

@ -9,6 +9,7 @@ @@ -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 @@ @@ -182,6 +183,11 @@
{expanded ? 'Show less' : 'Show more'}
</button>
{/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>
<style>
@ -231,4 +237,34 @@ @@ -231,4 +237,34 @@
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>

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

@ -0,0 +1,158 @@ @@ -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 @@ @@ -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 @@ @@ -119,6 +120,11 @@
{expanded ? 'Show less' : 'Show more'}
</button>
{/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>
<style>
@ -129,6 +135,7 @@ @@ -129,6 +135,7 @@
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-left: 3px solid var(--fog-accent, #64748b);
position: relative;
}
:global(.dark) .Feed-reply {
@ -178,4 +185,30 @@ @@ -178,4 +185,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;
}
</style>

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

@ -2,6 +2,7 @@ @@ -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 @@ @@ -90,8 +91,9 @@
<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>
{#if getZappedPubkey()}
{@const zappedPubkey = getZappedPubkey()!}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
to <ProfileBadge pubkey={getZappedPubkey()} />
to <ProfileBadge pubkey={zappedPubkey} />
</span>
{/if}
</div>
@ -122,6 +124,11 @@ @@ -122,6 +124,11 @@
{expanded ? 'Show less' : 'Show more'}
</button>
{/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>
<style>
@ -132,6 +139,7 @@ @@ -132,6 +139,7 @@
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-left: 3px solid #fbbf24; /* Gold/yellow for zaps */
position: relative;
}
:global(.dark) .zap-receipt-reply {
@ -172,4 +180,30 @@ @@ -172,4 +180,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;
}
</style>

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

@ -4,6 +4,7 @@ @@ -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 @@ @@ -250,11 +251,17 @@
{expanded ? 'Show less' : 'Show more'}
</button>
{/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>
<style>
.thread-card {
max-width: var(--content-width);
position: relative;
}
.thread-card a {
@ -284,4 +291,30 @@ @@ -284,4 +291,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;
}
</style>

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

@ -10,6 +10,7 @@ @@ -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 @@ @@ -151,6 +152,11 @@
<div class="comments-section">
<CommentThread threadId={thread.id} />
</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>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
@ -160,6 +166,7 @@ @@ -160,6 +166,7 @@
.thread-view {
max-width: var(--content-width);
margin: 0 auto;
position: relative;
}
.thread-content {
@ -203,4 +210,30 @@ @@ -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;
}
</style>

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

@ -8,7 +8,8 @@ const DEFAULT_RELAYS = [ @@ -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 = [ @@ -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 { @@ -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
};
}

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

@ -89,11 +89,33 @@ class NostrClient { @@ -89,11 +89,33 @@ class NostrClient {
async removeRelay(url: string): Promise<void> {
const relay = this.relays.get(url);
if (relay) {
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 { @@ -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<NostrEvent[]> {
const { useCache = true, cacheResults = true, onUpdate } = options || {};
@ -393,7 +415,7 @@ class NostrClient { @@ -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 { @@ -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<NostrEvent[]> {
return new Promise((resolve, reject) => {
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 timeout = options.timeout || config.relayTimeout; // Default 10 seconds
const client = this;
const finish = (eventArray: NostrEvent[]) => {
if (resolved) return;
resolved = true;
// Filter to only connected relays
let availableRelays = relays.filter(url => this.relays.has(url));
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
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 [];
}
if (eoseTimeout) {
clearTimeout(eoseTimeout);
eoseTimeout = null;
}
if (subId) {
client.unsubscribe(subId);
subId = null;
// Create one subscription per relay with all filters, sent in parallel
const events: Map<string, NostrEvent> = new Map();
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);
const filtered = client.filterEvents(eventArrayValues);
// Cache results
if (options.cacheResults && filtered.length > 0) {
cacheEvents(filtered).catch((error) => {
console.error('Error caching events:', error);
});
// Check if relay connection is still open, remove if closed
if (!client.checkAndCleanupRelay(relayUrl)) {
resolve();
return;
}
if (options.onUpdate) {
options.onUpdate(filtered);
}
const subId = `sub_${client.nextSubId++}_${Date.now()}`;
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) => {
// Skip hidden events
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);
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);
client.addToCache(event);
},
oneose() {
if (!resolved) {
finish();
}
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);
client.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub });
// 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();
}
}, 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);
}).catch(() => {
// If connection fails completely, return empty
finish([]);
});
return;
finish();
}
});
});
// Subscribe to events
if (availableRelays.length > 0) {
subId = this.subscribe(filters, availableRelays, onEvent, onEose);
// Wait for all relay requests to complete (or timeout)
await Promise.allSettled(relayPromises);
// Timeout after 30 seconds
timeoutId = setTimeout(() => {
if (!resolved) {
console.warn('fetchEvents timeout after 30 seconds');
finish(Array.from(events.values()));
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);
});
}
}, 30000);
} else {
// No relays available, return empty immediately
finish([]);
// Call onUpdate callback
if (options.onUpdate) {
options.onUpdate(filtered);
}
});
return filtered;
}
/**
* Get event by ID
*/

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

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

113
src/lib/types/kind-lookup.ts

@ -0,0 +1,113 @@ @@ -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