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

4
public/healthz.json

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

4
src/app.css

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

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

@ -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 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
interface Props { interface Props {
comment: NostrEvent; comment: NostrEvent;
@ -12,17 +11,9 @@
} }
let { comment, parentEvent, onReply }: Props = $props(); let { comment, parentEvent, onReply }: Props = $props();
let expanded = $state(false);
let parentPreview = $state<string | null>(null); let contentElement: HTMLElement | null = $state(null);
let parentHighlighted = $state(false); let needsExpansion = $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 ? '...' : '');
}
});
function getRelativeTime(): string { function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -42,67 +33,79 @@
return clientTag?.[1] || null; 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() { function handleReply() {
onReply?.(comment); 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> </script>
<article <article
id="comment-{comment.id}" id="comment-{comment.id}"
class="comment {parentHighlighted ? 'highlighted' : ''}" class="comment"
data-event-id={comment.id}
> >
{#if parentEvent && parentPreview} <div class="card-content" class:expanded bind:this={contentElement}>
<div {#if parentEvent}
class="parent-preview" <ReplyContext {parentEvent} targetId="comment-{parentEvent.id}" />
onclick={scrollToParent} {/if}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { <div class="comment-header flex items-center gap-2 mb-2">
e.preventDefault(); <ProfileBadge pubkey={comment.pubkey} />
scrollToParent(); <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>
role="button" {/if}
tabindex="0"
>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
↑ Replying to: {parentPreview}
</span>
</div> </div>
{/if}
<div class="comment-header flex items-center gap-2 mb-2"> <div class="comment-content mb-2">
<ProfileBadge pubkey={comment.pubkey} /> <MarkdownRenderer content={comment.content} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> </div>
{#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"> <div class="comment-actions flex gap-2">
<MarkdownRenderer content={comment.content} /> <button
onclick={handleReply}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
</div>
</div> </div>
<div class="comment-actions flex gap-2"> {#if needsExpansion}
<button <button
onclick={handleReply} onclick={toggleExpanded}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" 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> </button>
</div> {/if}
</article> </article>
<style> <style>
@ -119,48 +122,27 @@
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
} }
.comment.highlighted {
background: var(--fog-highlight, #f3f4f6); .comment-content {
border-color: var(--fog-accent, #64748b); line-height: 1.6;
animation: highlight 2s ease-out;
} }
:global(.dark) .comment.highlighted { .card-content {
background: var(--fog-dark-highlight, #374151); max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
} }
@keyframes highlight { .card-content.expanded {
0% { max-height: none;
background: var(--fog-accent, #64748b);
opacity: 0.3;
}
100% {
background: var(--fog-highlight, #f3f4f6);
opacity: 1;
}
} }
.parent-preview { .show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem; padding: 0.5rem;
margin-bottom: 0.5rem; background: transparent;
background: var(--fog-highlight, #f3f4f6); border: none;
border-left: 3px solid var(--fog-accent, #64748b);
border-radius: 0.25rem;
cursor: pointer; 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> </style>

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

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import Comment from './Comment.svelte'; import Comment from './Comment.svelte';
import CommentForm from './CommentForm.svelte'; import CommentForm from './CommentForm.svelte';
import ZapReceiptReply from '../feed/ZapReceiptReply.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
@ -12,6 +14,7 @@
let { threadId }: Props = $props(); let { threadId }: Props = $props();
let comments = $state<NostrEvent[]>([]); let comments = $state<NostrEvent[]>([]);
let zapReceipts = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null); let replyingTo = $state<NostrEvent | null>(null);
@ -24,10 +27,11 @@
loading = true; loading = true;
try { try {
const config = nostrClient.getConfig(); 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 // NIP-22: Comments use K tag for kind and E tag for event
const filters = [ const directCommentFilters = [
{ {
kinds: [1111], kinds: [1111],
'#K': ['11'], // Comments on kind 11 threads '#K': ['11'], // Comments on kind 11 threads
@ -35,15 +39,19 @@
} }
]; ];
const events = await nostrClient.fetchEvents( let directComments = await nostrClient.fetchEvents(
filters, directCommentFilters,
[...config.defaultRelays], relays,
{ useCache: true, cacheResults: true, onUpdate: (updated) => { { useCache: true, cacheResults: true }
comments = sortComments(updated);
}}
); );
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) { } catch (error) {
console.error('Error loading comments:', error); console.error('Error loading comments:', error);
} finally { } finally {
@ -51,17 +59,235 @@
} }
} }
function sortComments(events: NostrEvent[]): NostrEvent[] { async function fetchNestedReplies() {
// Sort by created_at ascending (oldest first) const relays = relayManager.getCommentReadRelays();
return [...events].sort((a, b) => a.created_at - b.created_at); 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 { function getParentEvent(event: NostrEvent): NostrEvent | undefined {
// NIP-22: E tag points to parent event // NIP-22: E tag (uppercase) points to parent event, or lowercase e tag
const eTag = comment.tags.find((t) => t[0] === 'E' || t[0] === 'e'); const eTag = event.tags.find((t) => t[0] === 'E') || event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) { if (eTag && eTag[1]) {
// Find parent in comments array // Find parent in comments or zap receipts
return comments.find((c) => c.id === eTag[1]); 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; return undefined;
} }
@ -81,17 +307,25 @@
{#if loading} {#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p> <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> <p class="text-fog-text-light dark:text-fog-dark-text-light">No comments yet. Be the first to comment!</p>
{:else} {:else}
<div class="comments-list"> <div class="comments-list">
{#each comments as comment (comment.id)} {#each getThreadItems() as item (item.event.id)}
{@const parent = getParentEvent(comment)} {@const parent = getParentEvent(item.event)}
<Comment {#if item.type === 'comment'}
{comment} <Comment
parentEvent={parent} comment={item.event}
onReply={handleReply} parentEvent={parent}
/> onReply={handleReply}
/>
{:else if item.type === 'zap'}
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parent}
onReply={handleReply}
/>
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}

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

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

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

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Kind1Post from './Kind1Post.svelte'; import FeedPost from './FeedPost.svelte';
import CreateKind1Form from './CreateKind1Form.svelte'; import CreateFeedForm from './CreateFeedForm.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js'; import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
@ -16,10 +16,12 @@
let newPostsCount = $state(0); let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null); let lastPostId = $state<string | null>(null);
let selectedIndex = $state<number>(-1); let selectedIndex = $state<number>(-1);
let showOPsOnly = $state(false);
onMount(async () => { onMount(() => {
await nostrClient.initialize(); nostrClient.initialize().then(() => {
loadFeed(); loadFeed();
});
// Set up infinite scroll // Set up infinite scroll
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
@ -117,7 +119,7 @@
} }
]; ];
const relays = relayManager.getKind1ReadRelays(); const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
filters, filters,
relays, relays,
@ -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) { if (reset) {
posts = sortPosts(events); posts = sortPosts(events);
lastPostId = events.length > 0 ? events[0].id : null; lastPostId = events.length > 0 ? events[0].id : null;
@ -178,8 +227,60 @@
} }
function sortPosts(events: NostrEvent[]): NostrEvent[] { function sortPosts(events: NostrEvent[]): NostrEvent[] {
// Sort by created_at descending (newest first) // Build thread structure
return [...events].sort((a, b) => b.created_at - a.created_at); 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) { function handleReply(post: NostrEvent) {
@ -199,23 +300,45 @@
newPostsCount = 0; newPostsCount = 0;
lastPostId = posts.length > 0 ? posts[0].id : null; 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> </script>
<div class="kind1-feed"> <div class="Feed-feed">
<div class="feed-header mb-4"> <div class="feed-header mb-4">
<h1 class="text-2xl font-bold mb-4">Feed</h1> <h1 class="text-2xl font-bold mb-4">Feed</h1>
<button <div class="feed-controls flex items-center gap-4">
onclick={() => (showNewPostForm = !showNewPostForm)} <label class="flex items-center gap-2 cursor-pointer">
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90" <input
> type="checkbox"
{showNewPostForm ? 'Cancel' : 'New Post'} bind:checked={showOPsOnly}
</button> 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> </div>
{#if showNewPostForm} {#if showNewPostForm}
<div class="new-post-form mb-4"> <div class="new-post-form mb-4">
<CreateKind1Form <CreateFeedForm
parentEvent={replyingTo} parentEvent={replyingTo || undefined}
onPublished={handlePostPublished} onPublished={handlePostPublished}
onCancel={() => { onCancel={() => {
showNewPostForm = false; showNewPostForm = false;
@ -241,23 +364,28 @@
</div> </div>
{/if} {/if}
<div class="posts-list"> <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"> <div data-post-id={post.id} class="post-wrapper">
<Kind1Post {post} onReply={handleReply} /> <FeedPost {post} parentEvent={parentEvent} onReply={handleReply} />
</div> </div>
{/each} {/each}
</div> </div>
{#if loadingMore} {#if loadingMore}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p> <p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p>
{/if} {/if}
{#if !hasMore && 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> <p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if} {/if}
{#if showOPsOnly && getFilteredPosts().length === 0 && posts.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No original posts found. Try unchecking "Show OPs only".</p>
{/if}
{/if} {/if}
</div> </div>
<style> <style>
.kind1-feed { .Feed-feed {
max-width: var(--content-width); max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
@ -267,6 +395,20 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .post-wrapper {

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

@ -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 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.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 ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.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'; import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
post: NostrEvent; post: NostrEvent;
parentEvent?: NostrEvent; // Optional parent event if already loaded
onReply?: (post: NostrEvent) => void; 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 { function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -36,45 +70,122 @@
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id); 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 { function getRootEventId(): string | null {
const rootTag = post.tags.find((t) => t[0] === 'root'); const rootTag = post.tags.find((t) => t[0] === 'root');
return rootTag?.[1] || null; 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> </script>
<article class="kind1-post"> <article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}>
<div class="post-header flex items-center gap-2 mb-2"> <div class="card-content" class:expanded bind:this={contentElement}>
<ProfileBadge pubkey={post.pubkey} /> {#if isReply() && parentEvent}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> <ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
{#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} {/if}
</div>
<div class="post-content mb-2"> <div class="post-header flex items-center gap-2 mb-2">
<MarkdownRenderer content={post.content} /> <ProfileBadge pubkey={post.pubkey} />
</div> <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"> <div class="post-content mb-2">
<Kind1ReactionButtons event={post} /> <MarkdownRenderer content={post.content} />
<ZapButton event={post} /> </div>
<ZapReceipt eventId={post.id} pubkey={post.pubkey} />
{#if onReply} <div class="post-actions flex items-center gap-4">
<button <FeedReactionButtons event={post} />
onclick={() => onReply(post)} <ZapButton event={post} />
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" <ZapReceipt eventId={post.id} pubkey={post.pubkey} />
> {#if onReply}
Reply <button
</button> onclick={() => onReply(post)}
{/if} class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</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> </article>
<style> <style>
.kind1-post { .Feed-post {
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
@ -82,7 +193,7 @@
border-radius: 0.25rem; border-radius: 0.25rem;
} }
:global(.dark) .kind1-post { :global(.dark) .Feed-post {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
} }
@ -100,4 +211,24 @@
:global(.dark) .post-actions { :global(.dark) .post-actions {
border-top-color: var(--fog-dark-border, #374151); 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> </style>

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

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

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

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import ReplyContext from '../../components/content/ReplyContext.svelte';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
@ -11,6 +10,9 @@
} }
let { zapReceipt, parentEvent, onReply }: Props = $props(); let { zapReceipt, parentEvent, onReply }: Props = $props();
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
function getRelativeTime(): string { function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -47,37 +49,79 @@
function getZapperPubkey(): string { function getZapperPubkey(): string {
return zapReceipt.pubkey; 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> </script>
<article class="zap-receipt-reply"> <article class="zap-receipt-reply" id="event-{zapReceipt.id}" data-event-id={zapReceipt.id}>
<div class="zap-header flex items-center gap-2 mb-2"> <div class="card-content" class:expanded bind:this={contentElement}>
<ProfileBadge pubkey={getZapperPubkey()} /> {#if parentEvent}
<span class="text-lg"></span> <ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
<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} {/if}
</div>
{#if zapReceipt.content} <div class="zap-header flex items-center gap-2 mb-2">
<div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text"> <ProfileBadge pubkey={getZapperPubkey()} />
{zapReceipt.content} <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> </div>
{/if}
<div class="zap-actions flex items-center gap-4"> {#if zapReceipt.content}
{#if onReply} <div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text">
<button {zapReceipt.content}
onclick={() => onReply(zapReceipt)} </div>
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if} {/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> </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> </article>
<style> <style>
@ -109,4 +153,23 @@
:global(.dark) .zap-actions { :global(.dark) .zap-actions {
border-top-color: var(--fog-dark-border, #374151); 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> </style>

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

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

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

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

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

@ -18,11 +18,41 @@
let zapCount = $state(0); let zapCount = $state(0);
let latestResponseTime = $state<number | null>(null); let latestResponseTime = $state<number | null>(null);
let loadingStats = $state(true); let loadingStats = $state(true);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
onMount(async () => { onMount(async () => {
await loadStats(); 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() { async function loadStats() {
loadingStats = true; loadingStats = true;
const timeout = 30000; // 30 seconds const timeout = 30000; // 30 seconds
@ -167,48 +197,59 @@
</script> </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"> <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"> <div class="card-content" class:expanded bind:this={contentElement}>
<h3 class="text-lg font-semibold"> <div class="flex justify-between items-start mb-2">
<a href="/thread/{thread.id}">{getTitle()}</a> <h3 class="text-lg font-semibold">
</h3> <a href="/thread/{thread.id}">{getTitle()}</a>
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> </h3>
</div> <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"> <div class="mb-2 flex items-center gap-2">
<ProfileBadge pubkey={thread.pubkey} /> <ProfileBadge pubkey={thread.pubkey} />
{#if getClientName()} {#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if} {/if}
</div> </div>
<p class="text-sm mb-2">{getPreview()}</p> <p class="text-sm mb-2">{getPreview()}</p>
{#if getTopics().length > 0} {#if getTopics().length > 0}
<div class="flex gap-2 mb-2"> <div class="flex gap-2 mb-2">
{#each getTopics() as topic} {#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> <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} {/each}
</div> </div>
{/if} {/if}
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text"> <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"> <div class="flex items-center gap-4 flex-wrap">
{#if loadingStats} {#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span> <span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else} {:else}
<span class="font-medium">{upvotes}</span> <span class="font-medium">{upvotes}</span>
<span class="font-medium">{downvotes}</span> <span class="font-medium">{downvotes}</span>
<span class="font-medium">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span> <span class="font-medium">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime} {#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span> <span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if} {/if}
{#if zapCount > 0} {#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span> <span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
{/if}
{/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> </div>
<a href="/thread/{thread.id}" class="ml-2 text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a>
</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> </article>
<style> <style>
@ -224,4 +265,23 @@
.thread-card a:hover { .thread-card a:hover {
text-decoration: underline; 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> </style>

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

@ -19,6 +19,9 @@
let thread = $state<NostrEvent | null>(null); let thread = $state<NostrEvent | null>(null);
let loading = $state(true); let loading = $state(true);
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
@ -72,6 +75,33 @@
if (hours > 0) return `${hours}h ago`; if (hours > 0) return `${hours}h ago`;
return 'just now'; 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> </script>
{#if loading} {#if loading}
@ -96,17 +126,28 @@
{/if} {/if}
</div> </div>
<div class="thread-content mb-4"> <div class="card-content" class:expanded bind:this={contentElement}>
<MediaAttachments event={thread} /> <div class="thread-content mb-4">
<MarkdownRenderer content={thread.content} /> <MediaAttachments event={thread} />
</div> <MarkdownRenderer content={thread.content} />
</div>
<div class="thread-actions flex items-center gap-4 mb-6"> <div class="thread-actions flex items-center gap-4 mb-6">
<ReactionButtons event={thread} /> <ReactionButtons event={thread} />
<ZapButton event={thread} /> <ZapButton event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} /> <ZapReceipt eventId={thread.id} pubkey={thread.pubkey} />
</div>
</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"> <div class="comments-section">
<CommentThread threadId={thread.id} /> <CommentThread threadId={thread.id} />
</div> </div>
@ -143,4 +184,23 @@
:global(.dark) .comments-section { :global(.dark) .comments-section {
border-top-color: var(--fog-dark-border, #374151); 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> </style>

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

@ -27,14 +27,43 @@ class NostrClient {
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.initialized) return; if (this.initialized) return;
// Connect to default relays // Set up global error handler for unhandled promise rejections from relays
for (const url of config.defaultRelays) { 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 { 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) { } 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; this.initialized = true;
} }
@ -191,9 +220,12 @@ class NostrClient {
): string { ): string {
const subId = `sub_${this.nextSubId++}_${Date.now()}`; 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) { for (const url of relays) {
const relay = this.relays.get(url); // Skip if relay is not in pool (will try to reconnect below)
if (!relay) { if (!this.relays.has(url)) {
// Try to connect if not already connected // Try to connect if not already connected
this.addRelay(url).then(() => { this.addRelay(url).then(() => {
const newRelay = this.relays.get(url); const newRelay = this.relays.get(url);
@ -201,12 +233,26 @@ class NostrClient {
this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose); this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose);
} }
}).catch((error) => { }).catch((error) => {
console.error(`Failed to connect to relay ${url}:`, error); console.debug(`Failed to connect to relay ${url}:`, error);
}); });
continue; 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; return subId;
@ -223,20 +269,65 @@ class NostrClient {
onEvent: (event: NostrEvent, relay: string) => void, onEvent: (event: NostrEvent, relay: string) => void,
onEose?: (relay: string) => void onEose?: (relay: string) => void
): void { ): void {
const client = this; // Check if relay is still in the pool (might have been removed due to close)
const sub = relay.subscribe(filters, { if (!this.relays.has(url)) {
onevent(event: NostrEvent) { console.warn(`Relay ${url} not in pool, skipping subscription`);
// Add to cache return;
client.addToCache(event); }
// Call callback
onEvent(event, url); // Wrap subscription in try-catch and handle both sync and async errors
}, try {
oneose() { const client = this;
onEose?.(url); 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 {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const events: Map<string, NostrEvent> = new Map(); const events: Map<string, NostrEvent> = new Map();
const relayCount = new Set<string>(); const relayCount = new Set<string>();
const connectedRelays = new Set<string>();
let resolved = false; let resolved = false;
let eoseTimeout: ReturnType<typeof setTimeout> | null = null; let eoseTimeout: ReturnType<typeof setTimeout> | null = null;
let timeoutId: 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[]) => { const finish = (eventArray: NostrEvent[]) => {
if (resolved) return; if (resolved) return;
@ -344,6 +438,11 @@ class NostrClient {
eoseTimeout = null; eoseTimeout = null;
} }
if (subId) {
client.unsubscribe(subId);
subId = null;
}
const eventArrayValues = Array.from(eventArray); const eventArrayValues = Array.from(eventArray);
const filtered = client.filterEvents(eventArrayValues); const filtered = client.filterEvents(eventArrayValues);
@ -367,30 +466,84 @@ class NostrClient {
events.set(event.id, event); events.set(event.id, event);
relayCount.add(relayUrl); relayCount.add(relayUrl);
connectedRelays.add(relayUrl);
}; };
const onEose = (relayUrl: string) => { const onEose = (relayUrl: string) => {
relayCount.add(relayUrl); 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) { if (eoseTimeout) {
clearTimeout(eoseTimeout); clearTimeout(eoseTimeout);
} }
eoseTimeout = setTimeout(() => { eoseTimeout = setTimeout(() => {
if (!resolved && relayCount.size >= Math.min(relays.length, 3)) { if (!resolved && subId) {
finish(Array.from(events.values())); 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 // 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 // Timeout after 30 seconds
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
if (!resolved) { if (!resolved) {
finish(Array.from(events.values())); console.warn('fetchEvents timeout after 30 seconds');
this.unsubscribe(subId); finish(Array.from(events.values()));
} }
}, 10000); }, 30000);
} else {
// No relays available, return empty immediately
finish([]);
}
}); });
} }

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

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

4
src/routes/feed/+page.svelte

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

Loading…
Cancel
Save