Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
f4ac0084f6
  1. 12
      README.md
  2. 4
      public/healthz.json
  3. 4
      src/app.css
  4. 63
      src/lib/components/content/ReplyContext.svelte
  5. 168
      src/lib/modules/comments/Comment.svelte
  6. 284
      src/lib/modules/comments/CommentThread.svelte
  7. 6
      src/lib/modules/feed/CreateFeedForm.svelte
  8. 184
      src/lib/modules/feed/FeedPage.svelte
  9. 234
      src/lib/modules/feed/FeedPost.svelte
  10. 189
      src/lib/modules/feed/Kind1Post.svelte
  11. 121
      src/lib/modules/feed/Kind1Reply.svelte
  12. 181
      src/lib/modules/feed/Reply.svelte
  13. 8
      src/lib/modules/feed/ReplyToKind1Form.svelte
  14. 113
      src/lib/modules/feed/ZapReceiptReply.svelte
  15. 8
      src/lib/modules/profiles/ProfilePage.svelte
  16. 6
      src/lib/modules/reactions/FeedReactionButtons.svelte
  17. 130
      src/lib/modules/threads/ThreadCard.svelte
  18. 76
      src/lib/modules/threads/ThreadView.svelte
  19. 217
      src/lib/services/nostr/nostr-client.ts
  20. 6
      src/lib/services/nostr/relay-manager.ts
  21. 4
      src/routes/feed/+page.svelte

12
README.md

@ -583,7 +583,7 @@ aitherboard/ @@ -583,7 +583,7 @@ aitherboard/
**Components**:
- `ReactionButtons.svelte` - For threads/comments (kind 11/1111)
- `Kind1ReactionButtons.svelte` - For kind 1 feed
- `FeedReactionButtons.svelte` - For kind 1 feed
**REQUIREMENTS**:
- **Kind 11/1111**: Only `+` and `-` allowed
@ -629,12 +629,12 @@ aitherboard/ @@ -629,12 +629,12 @@ aitherboard/
### Kind 1 Feed Module (`src/lib/modules/feed/`)
**Components**:
- `Kind1FeedPage.svelte` - Main feed page
- `Kind1Post.svelte` - Individual kind 1 post
- `Kind1Reply.svelte` - Kind 1 reply display
- `FeedPage.svelte` - Main feed page
- `FeedPost.svelte` - Individual kind 1 post
- `FeedReply.svelte` - Kind 1 reply display
- `ZapReceiptReply.svelte` - Zap receipt as reply (with ⚡)
- `CreateKind1Form.svelte` - Create new kind 1 events
- `ReplyToKind1Form.svelte` - Reply to kind 1 events
- `CreateFeedForm.svelte` - Create new kind 1 events
- `ReplyToFeedForm.svelte` - Reply to kind 1 events
**REQUIREMENTS**:
- "View feed" button on landing page opens `/feed`

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-02T14:49:18.071Z",
"buildTime": "2026-02-02T15:23:47.430Z",
"gitCommit": "unknown",
"timestamp": 1770043758071
"timestamp": 1770045827431
}

4
src/app.css

@ -117,12 +117,12 @@ img[src*="emoji" i] { @@ -117,12 +117,12 @@ img[src*="emoji" i] {
/* Apply grayscale filter to reaction buttons containing emojis */
.reaction-btn,
.kind1-reaction-buttons button {
.Feed-reaction-buttons button {
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
}
.dark .reaction-btn,
.dark .kind1-reaction-buttons button {
.dark .Feed-reaction-buttons button {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
}

63
src/lib/components/content/ReplyContext.svelte

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
parentEvent: NostrEvent;
targetId?: string; // Optional ID to scroll to (defaults to parent event ID)
}
let { parentEvent, targetId }: Props = $props();
function getParentPreview(): string {
// Create preview from parent (first 100 chars, plaintext)
const plaintext = parentEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim();
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
}
function scrollToParent() {
const elementId = targetId || `event-${parentEvent.id}`;
const element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${parentEvent.id}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('highlight-parent');
setTimeout(() => {
element.classList.remove('highlight-parent');
}, 2000);
}
}
</script>
<div
class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light cursor-pointer hover:opacity-80 transition-opacity"
onclick={scrollToParent}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToParent();
}
}}
>
<span class="font-semibold">Replying to:</span> {getParentPreview()}
</div>
<style>
.reply-context {
border-left: 2px solid var(--fog-accent, #64748b);
}
:global(.dark) .reply-context {
border-left-color: var(--fog-dark-accent, #64748b);
}
:global(.highlight-parent) {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
transition: outline 0.3s ease;
}
:global(.dark .highlight-parent) {
outline-color: var(--fog-dark-accent, #64748b);
}
</style>

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

@ -1,9 +1,8 @@ @@ -1,9 +1,8 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
interface Props {
comment: NostrEvent;
@ -12,17 +11,9 @@ @@ -12,17 +11,9 @@
}
let { comment, parentEvent, onReply }: Props = $props();
let parentPreview = $state<string | null>(null);
let parentHighlighted = $state(false);
onMount(() => {
if (parentEvent) {
// Create preview from parent (first 100 chars, plaintext)
const plaintext = parentEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim();
parentPreview = plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
}
});
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
@ -42,67 +33,79 @@ @@ -42,67 +33,79 @@
return clientTag?.[1] || null;
}
function scrollToParent() {
if (parentEvent) {
const element = document.getElementById(`comment-${parentEvent.id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
parentHighlighted = true;
setTimeout(() => {
parentHighlighted = false;
}, 2000);
}
}
}
function handleReply() {
onReply?.(comment);
}
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
<article
id="comment-{comment.id}"
class="comment {parentHighlighted ? 'highlighted' : ''}"
class="comment"
data-event-id={comment.id}
>
{#if parentEvent && parentPreview}
<div
class="parent-preview"
onclick={scrollToParent}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToParent();
}
}}
role="button"
tabindex="0"
>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
↑ Replying to: {parentPreview}
</span>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if parentEvent}
<ReplyContext {parentEvent} targetId="comment-{parentEvent.id}" />
{/if}
<div class="comment-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={comment.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>
{/if}
<div class="comment-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={comment.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 class="comment-content mb-2">
<MarkdownRenderer content={comment.content} />
</div>
<div class="comment-content mb-2">
<MarkdownRenderer content={comment.content} />
<div class="comment-actions flex gap-2">
<button
onclick={handleReply}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
</div>
</div>
<div class="comment-actions flex gap-2">
{#if needsExpansion}
<button
onclick={handleReply}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
Reply
{expanded ? 'Show less' : 'Show more'}
</button>
</div>
{/if}
</article>
<style>
@ -119,48 +122,27 @@ @@ -119,48 +122,27 @@
border-color: var(--fog-dark-border, #374151);
}
.comment.highlighted {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
animation: highlight 2s ease-out;
.comment-content {
line-height: 1.6;
}
:global(.dark) .comment.highlighted {
background: var(--fog-dark-highlight, #374151);
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
@keyframes highlight {
0% {
background: var(--fog-accent, #64748b);
opacity: 0.3;
}
100% {
background: var(--fog-highlight, #f3f4f6);
opacity: 1;
}
.card-content.expanded {
max-height: none;
}
.parent-preview {
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-left: 3px solid var(--fog-accent, #64748b);
border-radius: 0.25rem;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.2s;
}
:global(.dark) .parent-preview {
background: var(--fog-dark-highlight, #374151);
border-left-color: var(--fog-dark-accent, #64748b);
}
.parent-preview:hover {
background: var(--fog-accent, #64748b);
opacity: 0.1;
}
.comment-content {
line-height: 1.6;
}
</style>

284
src/lib/modules/comments/CommentThread.svelte

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
<script lang="ts">
import Comment from './Comment.svelte';
import CommentForm from './CommentForm.svelte';
import ZapReceiptReply from '../feed/ZapReceiptReply.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -12,6 +14,7 @@ @@ -12,6 +14,7 @@
let { threadId }: Props = $props();
let comments = $state<NostrEvent[]>([]);
let zapReceipts = $state<NostrEvent[]>([]);
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
@ -24,10 +27,11 @@ @@ -24,10 +27,11 @@
loading = true;
try {
const config = nostrClient.getConfig();
const relays = relayManager.getCommentReadRelays();
// Fetch comments (kind 1111) that reference this thread
// First, fetch comments (kind 1111) that directly reference this thread
// NIP-22: Comments use K tag for kind and E tag for event
const filters = [
const directCommentFilters = [
{
kinds: [1111],
'#K': ['11'], // Comments on kind 11 threads
@ -35,15 +39,19 @@ @@ -35,15 +39,19 @@
}
];
const events = await nostrClient.fetchEvents(
filters,
[...config.defaultRelays],
{ useCache: true, cacheResults: true, onUpdate: (updated) => {
comments = sortComments(updated);
}}
let directComments = await nostrClient.fetchEvents(
directCommentFilters,
relays,
{ useCache: true, cacheResults: true }
);
comments = sortComments(events);
comments = directComments;
// Recursively fetch all nested replies
await fetchNestedReplies();
// Fetch zap receipts that reference this thread or any comment
await fetchZapReceipts();
} catch (error) {
console.error('Error loading comments:', error);
} finally {
@ -51,17 +59,235 @@ @@ -51,17 +59,235 @@
}
}
function sortComments(events: NostrEvent[]): NostrEvent[] {
// Sort by created_at ascending (oldest first)
return [...events].sort((a, b) => a.created_at - b.created_at);
async function fetchNestedReplies() {
const relays = relayManager.getCommentReadRelays();
let hasNewComments = true;
let iterations = 0;
const maxIterations = 10; // Prevent infinite loops
// Keep fetching until we have all nested replies
while (hasNewComments && iterations < maxIterations) {
iterations++;
hasNewComments = false;
const allCommentIds = new Set(comments.map(c => c.id));
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);
hasNewComments = true;
}
}
}
// Also fetch missing parent comments that are referenced but not loaded
const missingReplyIds = new Set<string>();
for (const comment of comments) {
const eTag = comment.tags.find((t) => t[0] === 'E') || comment.tags.find((t) => t[0] === 'e' && t[1] !== comment.id);
if (eTag && eTag[1] && eTag[1] !== threadId) {
const parentExists = comments.some(c => c.id === eTag[1]);
if (!parentExists) {
missingReplyIds.add(eTag[1]);
}
}
}
if (missingReplyIds.size > 0) {
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) {
const exists = comments.some(c => c.id === reply.id);
if (!exists) {
comments.push(reply);
hasNewComments = true;
}
}
}
}
}
async function fetchZapReceipts() {
const config = nostrClient.getConfig();
const relays = relayManager.getCommentReadRelays();
// Keep fetching until we have all zaps
let previousCount = -1;
while (zapReceipts.length !== previousCount) {
previousCount = zapReceipts.length;
const allEventIds = new Set([threadId, ...comments.map(c => c.id), ...zapReceipts.map(z => z.id)]);
// Fetch zap receipts that reference thread or any comment/zap
const zapFilters = [
{
kinds: [9735],
'#e': Array.from(allEventIds) // Zap receipts for thread and all comments/zaps
}
];
const zapEvents = await nostrClient.fetchEvents(
zapFilters,
relays,
{ useCache: true, cacheResults: true }
);
const validZaps = zapEvents.filter(receipt => {
// Filter by threshold
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return !isNaN(amount) && amount >= config.zapThreshold;
}
return false;
});
// Add new zap receipts
const existingZapIds = new Set(zapReceipts.map(z => z.id));
for (const zap of validZaps) {
if (!existingZapIds.has(zap.id)) {
zapReceipts.push(zap);
}
}
// Check if any zaps reference comments we don't have
const missingCommentIds = new Set<string>();
for (const zap of validZaps) {
const eTag = zap.tags.find((t) => t[0] === 'e');
if (eTag && eTag[1] && eTag[1] !== threadId) {
if (!comments.some(c => c.id === eTag[1])) {
missingCommentIds.add(eTag[1]);
}
}
}
// Fetch missing comments
if (missingCommentIds.size > 0) {
const missingComments = await nostrClient.fetchEvents(
[{ kinds: [1111], ids: Array.from(missingCommentIds) }],
relays,
{ useCache: true, cacheResults: true }
);
for (const comment of missingComments) {
if (!comments.some(c => c.id === comment.id)) {
comments.push(comment);
}
}
// Fetch nested replies to newly found comments
await fetchNestedReplies();
}
}
}
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'zap' }> {
// Build thread structure similar to feed
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'zap' }>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = [];
const allEventIds = new Set<string>();
// First pass: build event map and collect all event IDs
for (const item of items) {
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
const eTag = item.event.tags.find((t) => t[0] === 'E') || item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id);
const parentId = eTag?.[1];
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 {
// Parent not found - treat as root item (might be a missing parent)
rootItems.push(item);
}
} else {
// No parent tag - this is a root item (direct reply to thread)
rootItems.push(item);
}
}
// Third pass: recursively collect all items in thread order
const result: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = [];
const processed = new Set<string>();
function addThread(item: { event: NostrEvent; type: 'comment' | 'zap' }) {
if (processed.has(item.event.id)) return;
processed.add(item.event.id);
result.push(item);
// Add all replies to this item
const replies = replyMap.get(item.event.id) || [];
const replyItems = replies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'zap' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at); // Sort replies chronologically
for (const reply of replyItems) {
addThread(reply);
}
}
// Add all root items sorted by time
rootItems.sort((a, b) => a.event.created_at - b.event.created_at);
for (const root of rootItems) {
addThread(root);
}
return result;
}
function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'zap' }> {
const items: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = [
...comments.map(c => ({ event: c, type: 'comment' as const })),
...zapReceipts.map(z => ({ event: z, type: 'zap' as const }))
];
return sortThreadItems(items);
}
function getParentEvent(comment: NostrEvent): NostrEvent | undefined {
// NIP-22: E tag points to parent event
const eTag = comment.tags.find((t) => t[0] === 'E' || t[0] === 'e');
function getParentEvent(event: NostrEvent): NostrEvent | undefined {
// NIP-22: E tag (uppercase) points to parent event, or lowercase e tag
const eTag = event.tags.find((t) => t[0] === 'E') || event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) {
// Find parent in comments array
return comments.find((c) => c.id === eTag[1]);
// Find parent in comments or zap receipts
const parent = comments.find((c) => c.id === eTag[1]) || zapReceipts.find((z) => z.id === eTag[1]);
if (parent) return parent;
// If parent not found, it might be the thread itself
return undefined;
}
return undefined;
}
@ -81,17 +307,25 @@ @@ -81,17 +307,25 @@
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p>
{:else if comments.length === 0}
{:else if comments.length === 0 && zapReceipts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No comments yet. Be the first to comment!</p>
{:else}
<div class="comments-list">
{#each comments as comment (comment.id)}
{@const parent = getParentEvent(comment)}
<Comment
{comment}
parentEvent={parent}
onReply={handleReply}
/>
{#each getThreadItems() as item (item.event.id)}
{@const parent = getParentEvent(item.event)}
{#if item.type === 'comment'}
<Comment
comment={item.event}
parentEvent={parent}
onReply={handleReply}
/>
{:else if item.type === 'zap'}
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parent}
onReply={handleReply}
/>
{/if}
{/each}
</div>
{/if}

6
src/lib/modules/feed/CreateKind1Form.svelte → src/lib/modules/feed/CreateFeedForm.svelte

@ -67,7 +67,7 @@ @@ -67,7 +67,7 @@
}
}
const relays = relayManager.getKind1PublishRelays(targetInbox);
const relays = relayManager.getFeedPublishRelays(targetInbox);
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
@ -85,7 +85,7 @@ @@ -85,7 +85,7 @@
}
</script>
<div class="create-kind1-form">
<div class="create-Feed-form">
{#if parentEvent}
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-sm">
Replying to: {parentEvent.content.slice(0, 100)}...
@ -132,7 +132,7 @@ @@ -132,7 +132,7 @@
</div>
<style>
.create-kind1-form {
.create-Feed-form {
margin-bottom: 1rem;
}

184
src/lib/modules/feed/Kind1FeedPage.svelte → src/lib/modules/feed/FeedPage.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang="ts">
import Kind1Post from './Kind1Post.svelte';
import CreateKind1Form from './CreateKind1Form.svelte';
import FeedPost from './FeedPost.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';
@ -16,10 +16,12 @@ @@ -16,10 +16,12 @@
let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null);
let selectedIndex = $state<number>(-1);
let showOPsOnly = $state(false);
onMount(async () => {
await nostrClient.initialize();
loadFeed();
onMount(() => {
nostrClient.initialize().then(() => {
loadFeed();
});
// Set up infinite scroll
window.addEventListener('scroll', handleScroll);
@ -117,7 +119,7 @@ @@ -117,7 +119,7 @@
}
];
const relays = relayManager.getKind1ReadRelays();
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
filters,
relays,
@ -144,6 +146,53 @@ @@ -144,6 +146,53 @@
}}
);
// Recursively fetch missing parent events to build complete threads
const allEventIds = new Set(events.map(e => e.id));
let missingParentIds = new Set<string>();
// 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);
}
}
// 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 }
);
// Add fetched events
for (const parentEvent of parentEvents) {
if (!allEventIds.has(parentEvent.id)) {
events.push(parentEvent);
allEventIds.add(parentEvent.id);
}
}
// 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);
}
}
missingParentIds = newMissingIds;
} catch (error) {
console.error('Error fetching missing parent events:', error);
break; // Stop trying if there's an error
}
}
if (reset) {
posts = sortPosts(events);
lastPostId = events.length > 0 ? events[0].id : null;
@ -178,8 +227,60 @@ @@ -178,8 +227,60 @@
}
function sortPosts(events: NostrEvent[]): NostrEvent[] {
// Sort by created_at descending (newest first)
return [...events].sort((a, b) => b.created_at - a.created_at);
// Build thread structure
const eventMap = new Map<string, NostrEvent>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootEvents: NostrEvent[] = [];
// First pass: build maps
for (const event of events) {
eventMap.set(event.id, event);
// Check if this is a reply
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1];
if (parentId && eventMap.has(parentId)) {
// This is a reply to an event we have
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(event.id);
} else {
// This is a root event (not a reply, or parent not loaded)
rootEvents.push(event);
}
}
// Second pass: recursively collect all events in thread order
const result: NostrEvent[] = [];
const processed = new Set<string>();
function addThread(event: NostrEvent) {
if (processed.has(event.id)) return;
processed.add(event.id);
result.push(event);
// Add all replies to this event
const replies = replyMap.get(event.id) || [];
const replyEvents = replies
.map(id => eventMap.get(id))
.filter((e): e is NostrEvent => e !== undefined)
.sort((a, b) => a.created_at - b.created_at); // Sort replies chronologically
for (const reply of replyEvents) {
addThread(reply);
}
}
// Add all root events sorted by newest first
rootEvents.sort((a, b) => b.created_at - a.created_at);
for (const root of rootEvents) {
addThread(root);
}
return result;
}
function handleReply(post: NostrEvent) {
@ -199,23 +300,45 @@ @@ -199,23 +300,45 @@
newPostsCount = 0;
lastPostId = posts.length > 0 ? posts[0].id : null;
}
function isReply(post: NostrEvent): boolean {
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (!replyTag || !replyTag[1]) return false;
// Check if the parent exists in the posts array
return posts.some(p => p.id === replyTag[1]);
}
function getFilteredPosts(): NostrEvent[] {
if (!showOPsOnly) return posts;
return posts.filter(post => !isReply(post));
}
</script>
<div class="kind1-feed">
<div class="Feed-feed">
<div class="feed-header mb-4">
<h1 class="text-2xl font-bold mb-4">Feed</h1>
<button
onclick={() => (showNewPostForm = !showNewPostForm)}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90"
>
{showNewPostForm ? 'Cancel' : 'New Post'}
</button>
<div class="feed-controls flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={showOPsOnly}
class="checkbox"
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show OPs only</span>
</label>
<button
onclick={() => (showNewPostForm = !showNewPostForm)}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90"
>
{showNewPostForm ? 'Cancel' : 'New Post'}
</button>
</div>
</div>
{#if showNewPostForm}
<div class="new-post-form mb-4">
<CreateKind1Form
parentEvent={replyingTo}
<CreateFeedForm
parentEvent={replyingTo || undefined}
onPublished={handlePostPublished}
onCancel={() => {
showNewPostForm = false;
@ -241,23 +364,28 @@ @@ -241,23 +364,28 @@
</div>
{/if}
<div class="posts-list">
{#each posts as post, index (post.id)}
{#each getFilteredPosts() as post, index (post.id)}
{@const parentId = post.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">
<Kind1Post {post} onReply={handleReply} />
<FeedPost {post} parentEvent={parentEvent} onReply={handleReply} />
</div>
{/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 && posts.length > 0}
{#if !hasMore && getFilteredPosts().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}
<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}
</div>
<style>
.kind1-feed {
.Feed-feed {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
@ -267,6 +395,20 @@ @@ -267,6 +395,20 @@
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.feed-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.checkbox {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.post-wrapper {

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

@ -0,0 +1,234 @@ @@ -0,0 +1,234 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
post: NostrEvent;
parentEvent?: NostrEvent; // Optional parent event if already loaded
onReply?: (post: NostrEvent) => void;
}
let { post, parentEvent: providedParentEvent, onReply }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
// Sync provided parent event changes and load if needed
$effect(() => {
if (providedParentEvent) {
// If provided parent event is available, use it
return;
}
// If no provided parent and this is a reply, try to load it
if (!loadedParentEvent && isReply()) {
loadParentEvent();
}
});
onMount(async () => {
// If parent not provided and this is a reply, try to load it
if (!providedParentEvent && !loadedParentEvent && isReply()) {
await loadParentEvent();
}
});
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - post.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 = post.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function isReply(): boolean {
// Check if this is a reply (has e tag pointing to another event)
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id);
}
function getReplyEventId(): string | null {
// Find the 'e' tag that's not the root (the direct parent)
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (replyTag) return replyTag[1];
// Fallback: find any 'e' tag that's not the root
const rootId = getRootEventId();
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id);
return eTag?.[1] || null;
}
function getRootEventId(): string | null {
const rootTag = post.tags.find((t) => t[0] === 'root');
return rootTag?.[1] || null;
}
async function loadParentEvent() {
const replyEventId = getReplyEventId();
if (!replyEventId || loadingParent) return;
loadingParent = true;
try {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [replyEventId] }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
loadedParentEvent = events[0];
}
} catch (error) {
console.error('Error loading parent event:', error);
} finally {
loadingParent = false;
}
}
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply() && parentEvent}
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
{/if}
<div class="post-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={post.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}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
{/if}
</div>
<div class="post-content mb-2">
<MarkdownRenderer content={post.content} />
</div>
<div class="post-actions flex items-center gap-4">
<FeedReactionButtons event={post} />
<ZapButton event={post} />
<ZapReceipt eventId={post.id} pubkey={post.pubkey} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
</article>
<style>
.Feed-post {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .Feed-post {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.post-content {
line-height: 1.6;
}
.post-actions {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .post-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
</style>

189
src/lib/modules/feed/Kind1Post.svelte

@ -1,17 +1,51 @@ @@ -1,17 +1,51 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import Kind1ReactionButtons from '../reactions/Kind1ReactionButtons.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
post: NostrEvent;
parentEvent?: NostrEvent; // Optional parent event if already loaded
onReply?: (post: NostrEvent) => void;
}
let { post, onReply }: Props = $props();
let { post, parentEvent: providedParentEvent, onReply }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
// Sync provided parent event changes and load if needed
$effect(() => {
if (providedParentEvent) {
// If provided parent event is available, use it
return;
}
// If no provided parent and this is a reply, try to load it
if (!loadedParentEvent && isReply()) {
loadParentEvent();
}
});
onMount(async () => {
// If parent not provided and this is a reply, try to load it
if (!providedParentEvent && !loadedParentEvent && isReply()) {
await loadParentEvent();
}
});
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
@ -36,45 +70,122 @@ @@ -36,45 +70,122 @@
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id);
}
function getReplyEventId(): string | null {
// Find the 'e' tag that's not the root (the direct parent)
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (replyTag) return replyTag[1];
// Fallback: find any 'e' tag that's not the root
const rootId = getRootEventId();
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id);
return eTag?.[1] || null;
}
function getRootEventId(): string | null {
const rootTag = post.tags.find((t) => t[0] === 'root');
return rootTag?.[1] || null;
}
async function loadParentEvent() {
const replyEventId = getReplyEventId();
if (!replyEventId || loadingParent) return;
loadingParent = true;
try {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [replyEventId] }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
loadedParentEvent = events[0];
}
} catch (error) {
console.error('Error loading parent event:', error);
} finally {
loadingParent = false;
}
}
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
<article class="kind1-post">
<div class="post-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={post.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}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply() && parentEvent}
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
{/if}
</div>
<div class="post-content mb-2">
<MarkdownRenderer content={post.content} />
</div>
<div class="post-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={post.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}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
{/if}
</div>
<div class="post-actions flex items-center gap-4">
<Kind1ReactionButtons event={post} />
<ZapButton event={post} />
<ZapReceipt eventId={post.id} pubkey={post.pubkey} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
<div class="post-content mb-2">
<MarkdownRenderer content={post.content} />
</div>
<div class="post-actions flex items-center gap-4">
<FeedReactionButtons event={post} />
<ZapButton event={post} />
<ZapReceipt eventId={post.id} pubkey={post.pubkey} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
</article>
<style>
.kind1-post {
.Feed-post {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
@ -82,7 +193,7 @@ @@ -82,7 +193,7 @@
border-radius: 0.25rem;
}
:global(.dark) .kind1-post {
:global(.dark) .Feed-post {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
@ -100,4 +211,24 @@ @@ -100,4 +211,24 @@
:global(.dark) .post-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
</style>

121
src/lib/modules/feed/Kind1Reply.svelte

@ -1,121 +0,0 @@ @@ -1,121 +0,0 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import Kind1ReactionButtons from '../reactions/Kind1ReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
reply: NostrEvent;
parentEvent?: NostrEvent; // The event this is replying to
onReply?: (post: NostrEvent) => void;
}
let { reply, parentEvent, onReply }: Props = $props();
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - reply.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 = reply.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function getParentPreview(): string {
if (parentEvent) {
return parentEvent.content.slice(0, 100) + (parentEvent.content.length > 100 ? '...' : '');
}
// Try to extract from reply tags
const eTag = reply.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (eTag) {
return 'Replying to...';
}
return '';
}
</script>
<article class="kind1-reply">
{#if parentEvent}
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light">
<span class="font-semibold">Replying to:</span> {getParentPreview()}
</div>
{/if}
<div class="reply-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={reply.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}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
</div>
<div class="reply-content mb-2">
<MarkdownRenderer content={reply.content} />
</div>
<div class="reply-actions flex items-center gap-4">
<Kind1ReactionButtons event={reply} />
<ZapButton event={reply} />
<ZapReceipt eventId={reply.id} pubkey={reply.pubkey} />
{#if onReply}
<button
onclick={() => onReply(reply)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</article>
<style>
.kind1-reply {
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) .kind1-reply {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: var(--fog-dark-accent, #64748b);
}
.reply-content {
line-height: 1.6;
}
.reply-actions {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .reply-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.reply-context {
cursor: pointer;
transition: opacity 0.2s;
}
.reply-context:hover {
opacity: 0.8;
}
</style>

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

@ -0,0 +1,181 @@ @@ -0,0 +1,181 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
reply: NostrEvent;
parentEvent?: NostrEvent; // The event this is replying to
onReply?: (post: NostrEvent) => void;
}
let { reply, parentEvent, onReply }: Props = $props();
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - reply.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 = reply.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function getParentPreview(): string {
if (parentEvent) {
return parentEvent.content.slice(0, 100) + (parentEvent.content.length > 100 ? '...' : '');
}
// Try to extract from reply tags
const eTag = reply.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (eTag) {
return 'Replying to...';
}
return '';
}
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
<article class="Feed-reply">
<div class="card-content" class:expanded bind:this={contentElement}>
{#if parentEvent}
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light">
<span class="font-semibold">Replying to:</span> {getParentPreview()}
</div>
{/if}
<div class="reply-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={reply.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}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
</div>
<div class="reply-content mb-2">
<MarkdownRenderer content={reply.content} />
</div>
<div class="reply-actions flex items-center gap-4">
<FeedReactionButtons event={reply} />
<ZapButton event={reply} />
<ZapReceipt eventId={reply.id} pubkey={reply.pubkey} />
{#if onReply}
<button
onclick={() => onReply(reply)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
</article>
<style>
.Feed-reply {
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) .Feed-reply {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: var(--fog-dark-accent, #64748b);
}
.reply-content {
line-height: 1.6;
}
.reply-actions {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .reply-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.reply-context {
cursor: pointer;
transition: opacity 0.2s;
}
.reply-context:hover {
opacity: 0.8;
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
</style>

8
src/lib/modules/feed/ReplyToKind1Form.svelte

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
parentEvent: NostrEvent; // The kind 1 event to reply to
parentEvent: NostrEvent; // The event to reply to
onPublished?: () => void;
onCancel?: () => void;
}
@ -62,7 +62,7 @@ @@ -62,7 +62,7 @@
// Ignore errors, just use default relays
}
const relays = relayManager.getKind1PublishRelays(targetInbox);
const relays = relayManager.getFeedPublishRelays(targetInbox);
const result = await signAndPublish(event, relays);
if (result.success.length > 0) {
@ -80,7 +80,7 @@ @@ -80,7 +80,7 @@
}
</script>
<div class="reply-to-kind1-form">
<div class="reply-to-Feed-form">
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-sm">
<span class="font-semibold">Replying to:</span> {parentEvent.content.slice(0, 100)}...
</div>
@ -125,7 +125,7 @@ @@ -125,7 +125,7 @@
</div>
<style>
.reply-to-kind1-form {
.reply-to-Feed-form {
margin-bottom: 1rem;
}

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

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
@ -11,6 +10,9 @@ @@ -11,6 +10,9 @@
}
let { zapReceipt, parentEvent, onReply }: Props = $props();
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
@ -47,37 +49,79 @@ @@ -47,37 +49,79 @@
function getZapperPubkey(): string {
return zapReceipt.pubkey;
}
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
<article class="zap-receipt-reply">
<div class="zap-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={getZapperPubkey()} />
<span class="text-lg"></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>
{#if getZappedPubkey()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
to <ProfileBadge pubkey={getZappedPubkey()} />
</span>
<article class="zap-receipt-reply" id="event-{zapReceipt.id}" data-event-id={zapReceipt.id}>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if parentEvent}
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
{/if}
</div>
{#if zapReceipt.content}
<div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text">
{zapReceipt.content}
<div class="zap-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={getZapperPubkey()} />
<span class="text-lg"></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>
{#if getZappedPubkey()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
to <ProfileBadge pubkey={getZappedPubkey()} />
</span>
{/if}
</div>
{/if}
<div class="zap-actions flex items-center gap-4">
{#if onReply}
<button
onclick={() => onReply(zapReceipt)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{#if zapReceipt.content}
<div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text">
{zapReceipt.content}
</div>
{/if}
<div class="zap-actions flex items-center gap-4">
{#if onReply}
<button
onclick={() => onReply(zapReceipt)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
</article>
<style>
@ -109,4 +153,23 @@ @@ -109,4 +153,23 @@
:global(.dark) .zap-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
</style>

8
src/lib/modules/profiles/ProfilePage.svelte

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import PaymentAddresses from './PaymentAddresses.svelte';
import Kind1Post from '../feed/Kind1Post.svelte';
import FeedPost from '../feed/FeedPost.svelte';
import { fetchProfile } from '../../services/auth/profile-fetcher.js';
import { fetchUserStatus } from '../../services/auth/user-status-fetcher.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
@ -56,7 +56,7 @@ @@ -56,7 +56,7 @@
posts = feedEvents.sort((a, b) => b.created_at - a.created_at);
// Load kind 1 responses (replies to this user's posts)
const responseRelays = relayManager.getKind1ResponseReadRelays();
const responseRelays = relayManager.getFeedResponseReadRelays();
const responseEvents = await nostrClient.fetchEvents(
[{ kinds: [1], '#p': [pubkey], limit: 20 }],
responseRelays,
@ -147,7 +147,7 @@ @@ -147,7 +147,7 @@
{:else}
<div class="posts-list">
{#each posts as post (post.id)}
<Kind1Post {post} />
<FeedPost {post} />
{/each}
</div>
{/if}
@ -157,7 +157,7 @@ @@ -157,7 +157,7 @@
{:else}
<div class="responses-list">
{#each responses as response (response.id)}
<Kind1Post post={response} />
<FeedPost post={response} />
{/each}
</div>
{/if}

6
src/lib/modules/reactions/Kind1ReactionButtons.svelte → src/lib/modules/reactions/FeedReactionButtons.svelte

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
event: NostrEvent; // Kind 1 event
event: NostrEvent; // Feed event
}
let { event }: Props = $props();
@ -138,7 +138,7 @@ @@ -138,7 +138,7 @@
let includeClientTag = $state(true);
</script>
<div class="kind1-reaction-buttons flex gap-2 items-center flex-wrap">
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
{#each commonReactions as reaction}
{@const count = getReactionCount(reaction)}
{#if count > 0 || reaction === '+' || showMore}
@ -182,7 +182,7 @@ @@ -182,7 +182,7 @@
</div>
<style>
.kind1-reaction-buttons {
.Feed-reaction-buttons {
margin-top: 0.5rem;
}

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

@ -18,11 +18,41 @@ @@ -18,11 +18,41 @@
let zapCount = $state(0);
let latestResponseTime = $state<number | null>(null);
let loadingStats = $state(true);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
onMount(async () => {
await loadStats();
});
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
async function loadStats() {
loadingStats = true;
const timeout = 30000; // 30 seconds
@ -167,48 +197,59 @@ @@ -167,48 +197,59 @@
</script>
<article class="thread-card bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 mb-4 shadow-sm dark:shadow-lg">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">
<a href="/thread/{thread.id}">{getTitle()}</a>
</h3>
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
</div>
<div class="card-content" class:expanded bind:this={contentElement}>
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">
<a href="/thread/{thread.id}">{getTitle()}</a>
</h3>
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
</div>
<div class="mb-2 flex items-center gap-2">
<ProfileBadge pubkey={thread.pubkey} />
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
</div>
<div class="mb-2 flex items-center gap-2">
<ProfileBadge pubkey={thread.pubkey} />
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
</div>
<p class="text-sm mb-2">{getPreview()}</p>
<p class="text-sm mb-2">{getPreview()}</p>
{#if getTopics().length > 0}
<div class="flex gap-2 mb-2">
{#each getTopics() as topic}
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if}
{#if getTopics().length > 0}
<div class="flex gap-2 mb-2">
{#each getTopics() as topic}
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if}
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text">
<div class="flex items-center gap-4 flex-wrap">
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium">{upvotes}</span>
<span class="font-medium">{downvotes}</span>
<span class="font-medium">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text">
<div class="flex items-center gap-4 flex-wrap">
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium">{upvotes}</span>
<span class="font-medium">{downvotes}</span>
<span class="font-medium">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
{/if}
{/if}
{/if}
</div>
<a href="/thread/{thread.id}" class="ml-2 text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a>
</div>
<a href="/thread/{thread.id}" class="ml-2 text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
</article>
<style>
@ -224,4 +265,23 @@ @@ -224,4 +265,23 @@
.thread-card a:hover {
text-decoration: underline;
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
</style>

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

@ -19,6 +19,9 @@ @@ -19,6 +19,9 @@
let thread = $state<NostrEvent | null>(null);
let loading = $state(true);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
onMount(async () => {
await nostrClient.initialize();
@ -72,6 +75,33 @@ @@ -72,6 +75,33 @@
if (hours > 0) return `${hours}h ago`;
return 'just now';
}
$effect(() => {
if (contentElement && thread) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
{#if loading}
@ -96,17 +126,28 @@ @@ -96,17 +126,28 @@
{/if}
</div>
<div class="thread-content mb-4">
<MediaAttachments event={thread} />
<MarkdownRenderer content={thread.content} />
</div>
<div class="card-content" class:expanded bind:this={contentElement}>
<div class="thread-content mb-4">
<MediaAttachments event={thread} />
<MarkdownRenderer content={thread.content} />
</div>
<div class="thread-actions flex items-center gap-4 mb-6">
<ReactionButtons event={thread} />
<ZapButton event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} />
<div class="thread-actions flex items-center gap-4 mb-6">
<ReactionButtons event={thread} />
<ZapButton event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} />
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<div class="comments-section">
<CommentThread threadId={thread.id} />
</div>
@ -143,4 +184,23 @@ @@ -143,4 +184,23 @@
:global(.dark) .comments-section {
border-top-color: var(--fog-dark-border, #374151);
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
</style>

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

@ -27,14 +27,43 @@ class NostrClient { @@ -27,14 +27,43 @@ class NostrClient {
async initialize(): Promise<void> {
if (this.initialized) return;
// Connect to default relays
for (const url of config.defaultRelays) {
// Set up global error handler for unhandled promise rejections from relays
if (typeof window !== 'undefined' && !(window as any).__nostrErrorHandlerSet) {
(window as any).__nostrErrorHandlerSet = true;
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
if (error && typeof error === 'object') {
const errorMessage = error.message || String(error);
if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed')) {
// Suppress these errors as they're handled by our connection management
event.preventDefault();
console.debug('Suppressed closed connection error:', errorMessage);
}
}
});
}
// Connect to default relays with timeout
const connectionPromises = config.defaultRelays.map(async (url) => {
try {
await this.addRelay(url);
// Add timeout to each connection attempt
await Promise.race([
this.addRelay(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), 10000)
)
]);
console.log(`Connected to relay: ${url}`);
} catch (error) {
console.error(`Failed to connect to relay ${url}:`, error);
console.warn(`Failed to connect to relay ${url}:`, error);
}
}
});
// Wait for all connection attempts (don't fail if some fail)
await Promise.allSettled(connectionPromises);
const connectedCount = this.relays.size;
console.log(`Initialized with ${connectedCount}/${config.defaultRelays.length} relays connected`);
this.initialized = true;
}
@ -191,9 +220,12 @@ class NostrClient { @@ -191,9 +220,12 @@ class NostrClient {
): string {
const subId = `sub_${this.nextSubId++}_${Date.now()}`;
// Filter to only active relays
const activeRelays = relays.filter(url => this.relays.has(url));
for (const url of relays) {
const relay = this.relays.get(url);
if (!relay) {
// Skip if relay is not in pool (will try to reconnect below)
if (!this.relays.has(url)) {
// Try to connect if not already connected
this.addRelay(url).then(() => {
const newRelay = this.relays.get(url);
@ -201,12 +233,26 @@ class NostrClient { @@ -201,12 +233,26 @@ class NostrClient {
this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose);
}
}).catch((error) => {
console.error(`Failed to connect to relay ${url}:`, error);
console.debug(`Failed to connect to relay ${url}:`, error);
});
continue;
}
this.setupSubscription(relay, url, subId, filters, onEvent, onEose);
const relay = this.relays.get(url);
if (!relay) continue; // Double-check (shouldn't happen, but safety check)
// Try to subscribe, handle errors if relay is closed
try {
this.setupSubscription(relay, url, subId, filters, onEvent, onEose);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) {
console.debug(`Relay ${url} is closed, removing from pool`);
this.relays.delete(url);
} else {
console.error(`Error subscribing to relay ${url}:`, error);
}
}
}
return subId;
@ -223,20 +269,65 @@ class NostrClient { @@ -223,20 +269,65 @@ class NostrClient {
onEvent: (event: NostrEvent, relay: string) => void,
onEose?: (relay: string) => void
): void {
const client = this;
const sub = relay.subscribe(filters, {
onevent(event: NostrEvent) {
// Add to cache
client.addToCache(event);
// Call callback
onEvent(event, url);
},
oneose() {
onEose?.(url);
}
});
// Check if relay is still in the pool (might have been removed due to close)
if (!this.relays.has(url)) {
console.warn(`Relay ${url} not in pool, skipping subscription`);
return;
}
// Wrap subscription in try-catch and handle both sync and async errors
try {
const client = this;
const sub = relay.subscribe(filters, {
onevent(event: NostrEvent) {
try {
// Check if relay is still in pool before processing
if (!client.relays.has(url)) return;
// Add to cache
client.addToCache(event);
// Call callback
onEvent(event, url);
} catch (err) {
console.error(`Error handling event from relay ${url}:`, err);
}
},
oneose() {
try {
// Check if relay is still in pool before processing
if (!client.relays.has(url)) return;
onEose?.(url);
} catch (err) {
console.error(`Error handling EOSE from relay ${url}:`, err);
}
}
});
this.subscriptions.set(`${url}_${subId}`, { relay, sub });
// Wrap subscription in a promise to catch async errors
Promise.resolve(sub).catch((err) => {
const errorMessage = err instanceof Error ? err.message : String(err);
if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) {
console.warn(`Relay ${url} subscription error (closed connection), removing from pool`);
this.relays.delete(url);
// Clean up this subscription
this.subscriptions.delete(`${url}_${subId}`);
} else {
console.error(`Relay ${url} subscription error:`, err);
}
});
this.subscriptions.set(`${url}_${subId}`, { relay, sub });
} catch (error) {
// Handle any other errors gracefully
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('closed') || errorMessage.includes('SendingOnClosedConnection')) {
console.warn(`Relay ${url} connection is closed, removing from pool`);
this.relays.delete(url);
return;
} else {
console.error(`Error setting up subscription on relay ${url}:`, error);
return;
}
}
}
/**
@ -327,9 +418,12 @@ class NostrClient { @@ -327,9 +418,12 @@ class NostrClient {
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 client = this;
const finish = (eventArray: NostrEvent[]) => {
if (resolved) return;
@ -344,6 +438,11 @@ class NostrClient { @@ -344,6 +438,11 @@ class NostrClient {
eoseTimeout = null;
}
if (subId) {
client.unsubscribe(subId);
subId = null;
}
const eventArrayValues = Array.from(eventArray);
const filtered = client.filterEvents(eventArrayValues);
@ -367,30 +466,84 @@ class NostrClient { @@ -367,30 +466,84 @@ class NostrClient {
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 && relayCount.size >= Math.min(relays.length, 3)) {
if (!resolved && subId) {
finish(Array.from(events.values()));
}
}, 1000);
}, 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
timeoutId = setTimeout(() => {
if (!resolved) {
console.warn('fetchEvents timeout after 30 seconds');
finish(Array.from(events.values()));
}
}, 30000);
}).catch(() => {
// If connection fails completely, return empty
finish([]);
});
return;
}
// Subscribe to events
const subId = this.subscribe(filters, relays, onEvent, onEose);
if (availableRelays.length > 0) {
subId = this.subscribe(filters, availableRelays, onEvent, onEose);
// Timeout after 10 seconds
timeoutId = setTimeout(() => {
if (!resolved) {
finish(Array.from(events.values()));
this.unsubscribe(subId);
}
}, 10000);
// Timeout after 30 seconds
timeoutId = setTimeout(() => {
if (!resolved) {
console.warn('fetchEvents timeout after 30 seconds');
finish(Array.from(events.values()));
}
}, 30000);
} else {
// No relays available, return empty immediately
finish([]);
}
});
}

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

@ -113,14 +113,14 @@ class RelayManager { @@ -113,14 +113,14 @@ class RelayManager {
/**
* Get relays for reading kind 1 feed
*/
getKind1ReadRelays(): string[] {
getFeedReadRelays(): string[] {
return this.getReadRelays(config.defaultRelays);
}
/**
* Get relays for reading kind 1 responses
*/
getKind1ResponseReadRelays(): string[] {
getFeedResponseReadRelays(): string[] {
return this.getReadRelays([
...config.defaultRelays,
'wss://aggr.nostr.land'
@ -195,7 +195,7 @@ class RelayManager { @@ -195,7 +195,7 @@ class RelayManager {
* Get relays for publishing kind 1 posts
* If replying, include target's inbox
*/
getKind1PublishRelays(targetInbox?: string[]): string[] {
getFeedPublishRelays(targetInbox?: string[]): string[] {
let relays = this.getPublishRelays(config.defaultRelays);
// If replying, add target's inbox

4
src/routes/feed/+page.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import Kind1FeedPage from '../../lib/modules/feed/Kind1FeedPage.svelte';
import FeedPage from '../../lib/modules/feed/FeedPage.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<Header />
<main class="container mx-auto px-4 py-8">
<Kind1FeedPage />
<FeedPage />
</main>
<style>

Loading…
Cancel
Save