Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
1d54143cb6
  1. 20
      src/lib/components/content/MarkdownRenderer.svelte
  2. 13
      src/lib/modules/comments/CommentForm.svelte
  3. 110
      src/lib/modules/comments/CommentThread.svelte
  4. 34
      src/lib/modules/feed/ThreadDrawer.svelte
  5. 23
      src/lib/modules/profiles/ProfilePage.svelte
  6. 269
      src/lib/modules/threads/ThreadView.svelte

20
src/lib/components/content/MarkdownRenderer.svelte

@ -213,10 +213,14 @@
} }
// Replace placeholder - it will be in a <code> tag after markdown parsing // Replace placeholder - it will be in a <code> tag after markdown parsing
const codePlaceholder = `<code>${placeholder.replace(/`/g, '')}</code>`; // The placeholder is like `NIP21PLACEHOLDER0`, which becomes <code>NIP21PLACEHOLDER0</code>
finalHtml = finalHtml.replace(new RegExp(codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement); const placeholderText = placeholder.replace(/`/g, ''); // Remove backticks
const codePlaceholder = `<code>${placeholderText}</code>`;
// Escape special regex characters
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement);
// Also try without code tag (in case markdown didn't process it) // Also try without code tag (in case markdown didn't process it)
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement); finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
} }
@ -301,8 +305,14 @@
} }
} }
// Escape placeholder for regex replacement // Replace placeholder - it will be in a <code> tag after markdown parsing
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const placeholderText = placeholder.replace(/`/g, ''); // Remove backticks
const codePlaceholder = `<code>${placeholderText}</code>`;
// Escape special regex characters
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement);
// Also try without code tag (in case markdown didn't process it)
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement); finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
} }

13
src/lib/modules/comments/CommentForm.svelte

@ -134,11 +134,11 @@
></textarea> ></textarea>
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-fog-text dark:text-fog-dark-text"> <label class="flex items-center gap-2 text-sm text-fog-text-light dark:text-fog-dark-text-light opacity-70">
<input <input
type="checkbox" type="checkbox"
bind:checked={includeClientTag} bind:checked={includeClientTag}
class="rounded" class="rounded client-tag-checkbox"
/> />
Include client tag Include client tag
</label> </label>
@ -178,4 +178,13 @@
outline: none; outline: none;
border-color: var(--fog-accent, #64748b); border-color: var(--fog-accent, #64748b);
} }
.client-tag-checkbox {
opacity: 0.7;
cursor: pointer;
}
.client-tag-checkbox:hover {
opacity: 0.9;
}
</style> </style>

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

@ -21,6 +21,7 @@
let zapReceipts = $state<NostrEvent[]>([]); // kind 9735 (zap receipts) let zapReceipts = $state<NostrEvent[]>([]); // kind 9735 (zap receipts)
let loading = $state(true); let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null); let replyingTo = $state<NostrEvent | null>(null);
let loadingPromise: Promise<void> | null = null; // Track ongoing load to prevent concurrent calls
const isKind1 = $derived(event?.kind === 1); const isKind1 = $derived(event?.kind === 1);
const rootKind = $derived(event?.kind || null); const rootKind = $derived(event?.kind || null);
@ -32,7 +33,27 @@
// Reload comments when threadId or event changes // Reload comments when threadId or event changes
$effect(() => { $effect(() => {
if (threadId) { if (threadId) {
loadComments(); // Access event to make it a dependency of this effect
// This ensures the effect re-runs when event changes from undefined to the actual event
const currentEvent = event;
const currentIsKind1 = isKind1;
// Prevent concurrent loads
if (loadingPromise) {
return;
}
// Load comments - filters will adapt based on whether event is available
// Ensure nostrClient is initialized first
loadingPromise = nostrClient.initialize().then(() => {
return loadComments();
}).catch((error) => {
console.error('Error initializing nostrClient in CommentThread:', error);
// Still try to load comments even if initialization fails
return loadComments();
}).finally(() => {
loadingPromise = null;
});
} }
}); });
@ -82,8 +103,12 @@
// For kind 1, 1244, 9735: check e tag // For kind 1, 1244, 9735: check e tag
if (replyEvent.kind === 1 || replyEvent.kind === 1244 || replyEvent.kind === 9735) { if (replyEvent.kind === 1 || replyEvent.kind === 1244 || replyEvent.kind === 9735) {
const eTag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== replyEvent.id); // For kind 1, check all e tags (NIP-10)
if (eTag && eTag[1]) return eTag[1]; const eTags = replyEvent.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== replyEvent.id);
// Prefer e tag with 'reply' marker, otherwise use first e tag
const replyTag = eTags.find((t) => t[3] === 'reply');
if (replyTag && replyTag[1]) return replyTag[1];
if (eTags.length > 0 && eTags[0][1]) return eTags[0][1];
} }
return null; return null;
@ -127,11 +152,16 @@
} }
async function loadComments() { async function loadComments() {
if (!threadId) {
loading = false;
return;
}
loading = true; loading = true;
try { try {
const relays = relayManager.getCommentReadRelays(); // Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache
const feedRelays = relayManager.getFeedReadRelays(); // getProfileReadRelays() includes: defaultRelays + profileRelays + user inbox (which includes local relays from kind 10432)
const allRelays = [...new Set([...relays, ...feedRelays])]; const allRelays = relayManager.getProfileReadRelays();
const replyFilters: any[] = []; const replyFilters: any[] = [];
@ -153,23 +183,34 @@
// Fetch zap receipts (kind 9735) // Fetch zap receipts (kind 9735)
replyFilters.push({ kinds: [9735], '#e': [threadId] }); replyFilters.push({ kinds: [9735], '#e': [threadId] });
console.log('CommentThread: Loading comments for threadId:', threadId, 'event kind:', event?.kind);
console.log('CommentThread: Filters:', replyFilters);
const allReplies = await nostrClient.fetchEvents( const allReplies = await nostrClient.fetchEvents(
replyFilters, replyFilters,
allRelays, allRelays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true, timeout: 10000 }
); );
console.log('CommentThread: Fetched', allReplies.length, 'replies');
// Filter to only replies that reference the root // Filter to only replies that reference the root
const rootReplies = allReplies.filter(reply => referencesRoot(reply)); const rootReplies = allReplies.filter(reply => referencesRoot(reply));
console.log('CommentThread: Root replies:', rootReplies.length);
// Separate by type // Separate by type
comments = rootReplies.filter(e => e.kind === 1111); comments = rootReplies.filter(e => e.kind === 1111);
kind1Replies = rootReplies.filter(e => e.kind === 1); kind1Replies = rootReplies.filter(e => e.kind === 1);
yakBacks = rootReplies.filter(e => e.kind === 1244); yakBacks = rootReplies.filter(e => e.kind === 1244);
zapReceipts = rootReplies.filter(e => e.kind === 9735); zapReceipts = rootReplies.filter(e => e.kind === 9735);
console.log('CommentThread: Separated - comments:', comments.length, 'kind1Replies:', kind1Replies.length, 'yakBacks:', yakBacks.length, 'zapReceipts:', zapReceipts.length);
// Recursively fetch all nested replies // Recursively fetch all nested replies (non-blocking - let it run in background)
await fetchNestedReplies(); fetchNestedReplies().catch((error) => {
console.error('Error fetching nested replies:', error);
});
} catch (error) { } catch (error) {
console.error('Error loading comments:', error); console.error('Error loading comments:', error);
@ -179,9 +220,8 @@
} }
async function fetchNestedReplies() { async function fetchNestedReplies() {
const relays = relayManager.getCommentReadRelays(); // Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache
const feedRelays = relayManager.getFeedReadRelays(); const allRelays = relayManager.getProfileReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])];
let hasNewReplies = true; let hasNewReplies = true;
let iterations = 0; let iterations = 0;
const maxIterations = 10; const maxIterations = 10;
@ -272,16 +312,32 @@
// Second pass: determine parent-child relationships // Second pass: determine parent-child relationships
for (const item of items) { for (const item of items) {
const parentId = getParentEventId(item.event); const parentId = getParentEventId(item.event);
console.log('CommentThread: sortThreadItems - item:', item.type, item.event.id.slice(0, 8), 'parentId:', parentId ? parentId.slice(0, 8) : 'null', 'threadId:', threadId.slice(0, 8));
if (parentId && (parentId === threadId || allEventIds.has(parentId))) { if (parentId) {
// This is a reply if (parentId === threadId) {
if (!replyMap.has(parentId)) { // This is a direct reply to the root OP
replyMap.set(parentId, []); if (!replyMap.has(threadId)) {
replyMap.set(threadId, []);
}
replyMap.get(threadId)!.push(item.event.id);
console.log('CommentThread: Added to replyMap for root threadId:', threadId.slice(0, 8));
} else if (allEventIds.has(parentId)) {
// This is a reply to another reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
console.log('CommentThread: Added to replyMap for parent:', parentId.slice(0, 8));
} else {
// Parent not found - treat as root item (orphaned reply)
rootItems.push(item);
console.log('CommentThread: Added to rootItems (orphaned)');
} }
replyMap.get(parentId)!.push(item.event.id);
} else { } else {
// No parent or parent not found - treat as root item // No parent - treat as root item (direct reply without parent tag)
rootItems.push(item); rootItems.push(item);
console.log('CommentThread: Added to rootItems (no parent)');
} }
} }
@ -307,7 +363,18 @@
} }
} }
// Add all root items sorted by time // First, add all direct replies to the root OP (threadId)
const rootReplies = replyMap.get(threadId) || [];
const rootReplyItems = rootReplies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of rootReplyItems) {
addThread(reply);
}
// Then, add all root items (orphaned replies) sorted by time
rootItems.sort((a, b) => a.event.created_at - b.event.created_at); rootItems.sort((a, b) => a.event.created_at - b.event.created_at);
for (const root of rootItems) { for (const root of rootItems) {
addThread(root); addThread(root);
@ -323,7 +390,10 @@
...yakBacks.map(y => ({ event: y, type: 'yak' as const })), ...yakBacks.map(y => ({ event: y, type: 'yak' as const })),
...zapReceipts.map(z => ({ event: z, type: 'zap' as const })) ...zapReceipts.map(z => ({ event: z, type: 'zap' as const }))
]; ];
return sortThreadItems(items); console.log('CommentThread: getThreadItems - items before sort:', items.length, items.map(i => ({ type: i.type, id: i.event.id.slice(0, 8) })));
const sorted = sortThreadItems(items);
console.log('CommentThread: getThreadItems - items after sort:', sorted.length, sorted.map(i => ({ type: i.type, id: i.event.id.slice(0, 8) })));
return sorted;
} }
function handleReply(replyEvent: NostrEvent) { function handleReply(replyEvent: NostrEvent) {

34
src/lib/modules/feed/ThreadDrawer.svelte

@ -1,14 +1,13 @@
<script lang="ts"> <script lang="ts">
import { fade, slide } from 'svelte/transition'; import { fade, slide } from 'svelte/transition';
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import CommentThread from '../comments/CommentThread.svelte'; import CommentThread from '../comments/CommentThread.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 type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
opEvent: NostrEvent | null; // The original post/event opEvent: NostrEvent | null; // The event that was clicked
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
@ -16,7 +15,7 @@
let { opEvent, isOpen, onClose }: Props = $props(); let { opEvent, isOpen, onClose }: Props = $props();
let loading = $state(false); let loading = $state(false);
let rootEvent = $state<NostrEvent | null>(null); // The actual OP/root event let rootEvent = $state<NostrEvent | null>(null);
// Load root event when drawer opens // Load root event when drawer opens
$effect(() => { $effect(() => {
@ -101,20 +100,15 @@
loading = true; loading = true;
try { try {
const relays = relayManager.getFeedReadRelays(); // Find the root OP event by traversing up the chain
// First, find the root OP event
rootEvent = await findRootEvent(opEvent); rootEvent = await findRootEvent(opEvent);
// Root event is now loaded, CommentThread will handle loading replies
} catch (error) { } catch (error) {
console.error('Error loading thread:', error); console.error('Error loading root event:', error);
} finally { } finally {
loading = false; loading = false;
} }
} }
function handleBackdropClick(e: MouseEvent) { function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose();
@ -130,7 +124,7 @@
<svelte:window onkeydown={handleEscape} /> <svelte:window onkeydown={handleEscape} />
{#if isOpen} {#if isOpen}
<div <div
class="drawer-backdrop" class="drawer-backdrop"
onclick={handleBackdropClick} onclick={handleBackdropClick}
@ -156,15 +150,12 @@
{#if loading} {#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p>
{:else if rootEvent} {:else if rootEvent}
<!-- OP with reactions --> <!-- Display the root OP event -->
<div class="op-section"> <div class="op-section">
<FeedPost post={rootEvent} /> <FeedPost post={rootEvent} />
<div class="reactions-section">
<FeedReactionButtons event={rootEvent} />
</div>
</div> </div>
<!-- Threaded replies using CommentThread --> <!-- Display all replies using CommentThread -->
<div class="replies-section"> <div class="replies-section">
<CommentThread threadId={rootEvent.id} event={rootEvent} /> <CommentThread threadId={rootEvent.id} event={rootEvent} />
</div> </div>
@ -269,18 +260,7 @@
border-bottom-color: var(--fog-dark-border, #374151); border-bottom-color: var(--fog-dark-border, #374151);
} }
.reactions-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .reactions-section {
border-top-color: var(--fog-dark-border, #374151);
}
.replies-section { .replies-section {
margin-top: 2rem; margin-top: 2rem;
} }
</style> </style>

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

@ -3,6 +3,7 @@
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 FeedPost from '../feed/FeedPost.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js'; import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js';
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';
@ -24,6 +25,20 @@
// Get current logged-in user's pubkey // Get current logged-in user's pubkey
let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey()); let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey());
// Drawer state for viewing threads
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
// Subscribe to session changes // Subscribe to session changes
$effect(() => { $effect(() => {
const unsubscribe = sessionManager.session.subscribe((session) => { const unsubscribe = sessionManager.session.subscribe((session) => {
@ -371,7 +386,7 @@
{:else} {:else}
<div class="posts-list"> <div class="posts-list">
{#each posts as post (post.id)} {#each posts as post (post.id)}
<FeedPost {post} /> <FeedPost post={post} onOpenEvent={openDrawer} />
{/each} {/each}
</div> </div>
{/if} {/if}
@ -381,7 +396,7 @@
{:else} {:else}
<div class="responses-list"> <div class="responses-list">
{#each responses as response (response.id)} {#each responses as response (response.id)}
<FeedPost post={response} /> <FeedPost post={response} onOpenEvent={openDrawer} />
{/each} {/each}
</div> </div>
{/if} {/if}
@ -391,7 +406,7 @@
{:else} {:else}
<div class="interactions-list"> <div class="interactions-list">
{#each interactionsWithMe as interaction (interaction.id)} {#each interactionsWithMe as interaction (interaction.id)}
<FeedPost post={interaction} /> <FeedPost post={interaction} onOpenEvent={openDrawer} />
{/each} {/each}
</div> </div>
{/if} {/if}
@ -400,6 +415,8 @@
{:else} {:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p>
{/if} {/if}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
</div> </div>
<style> <style>

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

@ -1,16 +1,10 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import CommentThread from '../comments/CommentThread.svelte'; import CommentThread from '../comments/CommentThread.svelte';
import ReactionButtons from '../reactions/ReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.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 { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
interface Props { interface Props {
threadId: string; threadId: string;
@ -18,158 +12,108 @@
let { threadId }: Props = $props(); let { threadId }: Props = $props();
let thread = $state<NostrEvent | null>(null); let rootEvent = $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();
loadThread(); loadRootEvent();
}); });
$effect(() => { $effect(() => {
if (threadId) { if (threadId) {
loadThread(); loadRootEvent();
} }
}); });
async function loadThread() { /**
* Find the root OP event by traversing up the reply chain
*/
async function findRootEvent(event: NostrEvent, visited: Set<string> = new Set()): Promise<NostrEvent> {
// Prevent infinite loops
if (visited.has(event.id)) {
return event;
}
visited.add(event.id);
// Check for 'root' tag first (NIP-10) - this directly points to the root
const rootTag = event.tags.find((t) => t[0] === 'root');
if (rootTag && rootTag[1]) {
// If root tag points to self, we're already at root
if (rootTag[1] === event.id) {
return event;
}
const relays = relayManager.getFeedReadRelays();
const rootEvent = await nostrClient.getEventById(rootTag[1], relays);
if (rootEvent) {
return rootEvent;
}
}
// Check if this event has a parent 'e' tag (NIP-10)
const eTags = event.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== event.id);
// Prefer 'e' tag with 'reply' marker (NIP-10)
let parentId: string | undefined;
const replyTag = eTags.find((t) => t[3] === 'reply');
if (replyTag) {
parentId = replyTag[1];
} else if (eTags.length > 0) {
// Use first 'e' tag if no explicit reply marker
parentId = eTags[0][1];
}
if (!parentId) {
// No parent - this is the root
return event;
}
const relays = relayManager.getFeedReadRelays();
const parent = await nostrClient.getEventById(parentId, relays);
if (!parent) {
// Parent not found - treat current event as root
return event;
}
// Recursively find root
return findRootEvent(parent, visited);
}
async function loadRootEvent() {
loading = true; loading = true;
try { try {
// Try multiple relay sets to find the event
const threadRelays = relayManager.getThreadReadRelays(); const threadRelays = relayManager.getThreadReadRelays();
const feedRelays = relayManager.getFeedReadRelays(); const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...threadRelays, ...feedRelays])]; const allRelays = [...new Set([...threadRelays, ...feedRelays])];
// Load the event by ID
const event = await nostrClient.getEventById(threadId, allRelays); const event = await nostrClient.getEventById(threadId, allRelays);
thread = event; if (event) {
// Find the root OP by traversing up the chain
rootEvent = await findRootEvent(event);
}
} catch (error) { } catch (error) {
console.error('Error loading thread:', error); console.error('Error loading thread:', error);
} finally { } finally {
loading = false; loading = false;
} }
} }
function getTitle(): string {
if (!thread) return '';
// For kind 11 threads, use title tag
if (thread.kind === 11) {
const titleTag = thread.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
}
// For other kinds, show kind description or first line of content
const firstLine = thread.content.split('\n')[0].trim();
if (firstLine.length > 0 && firstLine.length < 100) {
return firstLine;
}
return getKindInfo(thread.kind).description;
}
function getTopics(): string[] {
if (!thread) return [];
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3);
}
function getClientName(): string | null {
if (!thread) return null;
const clientTag = thread.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function getRelativeTime(): string {
if (!thread) return '';
const now = Math.floor(Date.now() / 1000);
const diff = now - thread.created_at;
const hours = Math.floor(diff / 3600);
const days = Math.floor(diff / 86400);
if (days > 0) return `${days}d ago`;
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> </script>
{#if loading} {#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p> <p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p>
{:else if thread} {:else if rootEvent}
<article class="thread-view"> <article class="thread-view">
<div class="thread-header mb-4"> <!-- Display the root OP event -->
<h1 class="text-2xl font-bold mb-2">{getTitle()}</h1> <div class="op-section">
<div class="flex items-center gap-2 mb-2"> <FeedPost post={rootEvent} />
<ProfileBadge pubkey={thread.pubkey} />
<span class="text-sm 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 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>
<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 justify-between gap-4 mb-6">
<div class="flex items-center gap-4">
<ReactionButtons event={thread} />
<ZapButton event={thread} />
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} />
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(thread.kind).number}</span>
<span class="kind-description">{getKindInfo(thread.kind).description}</span>
</div>
</div>
</div> </div>
{#if needsExpansion} <!-- Display all replies using CommentThread -->
<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} event={thread} /> <CommentThread threadId={rootEvent.id} event={rootEvent} />
</div> </div>
</article> </article>
{:else} {:else}
@ -180,77 +124,20 @@
.thread-view { .thread-view {
max-width: var(--content-width); max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
position: relative; padding: 1rem;
}
.thread-content {
line-height: 1.6;
} }
.thread-actions { .op-section {
padding-top: 1rem; margin-bottom: 2rem;
border-top: 1px solid var(--fog-border, #e5e7eb); padding-bottom: 1rem;
} border-bottom: 2px solid var(--fog-border, #e5e7eb);
.thread-actions > div {
display: flex;
align-items: center;
} }
:global(.dark) .thread-actions { :global(.dark) .op-section {
border-top-color: var(--fog-dark-border, #374151); border-bottom-color: var(--fog-dark-border, #374151);
} }
.comments-section { .comments-section {
margin-top: 2rem; margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
: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;
}
.kind-badge {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
flex-shrink: 0;
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
}
.kind-number {
font-weight: 600;
}
.kind-description {
font-size: 0.625rem;
opacity: 0.8;
} }
</style> </style>

Loading…
Cancel
Save