diff --git a/src/lib/modules/comments/Comment.svelte b/src/lib/modules/comments/Comment.svelte index 25200d8..1c4734b 100644 --- a/src/lib/modules/comments/Comment.svelte +++ b/src/lib/modules/comments/Comment.svelte @@ -138,6 +138,10 @@ line-height: 1.6; } + .comment-actions { + padding-right: 6rem; /* Reserve space for kind badge */ + } + .card-content { max-height: 500px; overflow: hidden; @@ -162,9 +166,9 @@ bottom: 0.5rem; right: 0.5rem; display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.125rem; + flex-direction: row; + align-items: center; + gap: 0.25rem; font-size: 0.625rem; line-height: 1; color: var(--fog-text-light, #9ca3af); @@ -179,7 +183,7 @@ } .kind-description { - font-size: 0.5rem; + font-size: 0.625rem; opacity: 0.8; } diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index 2839183..c01e466 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -22,6 +22,8 @@ let loading = $state(true); let replyingTo = $state(null); let loadingPromise: Promise | null = null; // Track ongoing load to prevent concurrent calls + let nestedSubscriptionActive = $state(false); // Track if nested subscription is active + let isProcessingUpdate = $state(false); // Prevent recursive update processing const isKind1 = $derived(event?.kind === 1); const rootKind = $derived(event?.kind || null); @@ -33,6 +35,13 @@ // Reload comments when threadId changes $effect(() => { if (!threadId) { + // Reset state when threadId is cleared + comments = []; + kind1Replies = []; + yakBacks = []; + zapReceipts = []; + nestedSubscriptionActive = false; + isProcessingUpdate = false; return; } @@ -41,6 +50,10 @@ return; } + // Reset subscription flag when loading new thread + nestedSubscriptionActive = false; + isProcessingUpdate = false; + // Load comments - filters will adapt based on whether event is available // Ensure nostrClient is initialized first loadingPromise = nostrClient.initialize().then(() => { @@ -148,53 +161,119 @@ return !!eTag; } + function handleReplyUpdate(updated: NostrEvent[]) { + // Prevent recursive calls + if (isProcessingUpdate) { + return; + } + + // Batch updates to prevent flickering + requestAnimationFrame(() => { + isProcessingUpdate = true; + + try { + let hasNewReplies = false; + const commentsMap = new Map(comments.map(c => [c.id, c])); + const kind1RepliesMap = new Map(kind1Replies.map(r => [r.id, r])); + const yakBacksMap = new Map(yakBacks.map(y => [y.id, y])); + const zapReceiptsMap = new Map(zapReceipts.map(z => [z.id, z])); + + for (const reply of updated) { + // Skip if we already have this reply + if (commentsMap.has(reply.id) || kind1RepliesMap.has(reply.id) || + yakBacksMap.has(reply.id) || zapReceiptsMap.has(reply.id)) { + continue; + } + + // Check if this reply references the root OR is a reply to any existing comment/reply + const parentId = getParentEventId(reply); + const isReplyToRoot = referencesRoot(reply); + const isReplyToExisting = parentId && ( + parentId === threadId || + commentsMap.has(parentId) || + kind1RepliesMap.has(parentId) || + yakBacksMap.has(parentId) || + zapReceiptsMap.has(parentId) + ); + + if (!isReplyToRoot && !isReplyToExisting) { + continue; + } + + // Add the reply to the appropriate map + if (reply.kind === 1111) { + commentsMap.set(reply.id, reply); + hasNewReplies = true; + } else if (reply.kind === 1) { + kind1RepliesMap.set(reply.id, reply); + hasNewReplies = true; + } else if (reply.kind === 1244) { + yakBacksMap.set(reply.id, reply); + hasNewReplies = true; + } else if (reply.kind === 9735) { + zapReceiptsMap.set(reply.id, reply); + hasNewReplies = true; + } + } + + // Only update state if we have new replies + if (hasNewReplies) { + comments = Array.from(commentsMap.values()); + kind1Replies = Array.from(kind1RepliesMap.values()); + yakBacks = Array.from(yakBacksMap.values()); + zapReceipts = Array.from(zapReceiptsMap.values()); + } + } finally { + isProcessingUpdate = false; + } + }); + } + async function loadComments() { if (!threadId) { loading = false; return; } - loading = true; + const allRelays = relayManager.getProfileReadRelays(); + const replyFilters: any[] = [ + { kinds: [1111], '#e': [threadId] }, + { kinds: [1111], '#E': [threadId] }, + { kinds: [1111], '#a': [threadId] }, + { kinds: [1111], '#A': [threadId] }, + { kinds: [1], '#e': [threadId] }, + { kinds: [1244], '#e': [threadId] }, + { kinds: [9735], '#e': [threadId] } + ]; + + // fetchEvents with useCache:true returns cached data immediately if available, + // then fetches fresh data in background. Only show loading if no cache. try { - // Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache - // getProfileReadRelays() includes: defaultRelays + profileRelays + user inbox (which includes local relays from kind 10432) - const allRelays = relayManager.getProfileReadRelays(); - - const replyFilters: any[] = []; - - // Always fetch kind 1111 comments - check both e and E tags, and a and A tags - replyFilters.push( - { kinds: [1111], '#e': [threadId] }, // Lowercase e tag - { kinds: [1111], '#E': [threadId] }, // Uppercase E tag (NIP-22) - { kinds: [1111], '#a': [threadId] }, // Lowercase a tag (some clients use wrong tags) - { kinds: [1111], '#A': [threadId] } // Uppercase A tag (NIP-22 for addressable events) + // Quick cache check - if we have cache, don't show loading + const quickCacheCheck = await nostrClient.fetchEvents( + replyFilters, + allRelays, + { useCache: true, cacheResults: false, timeout: 50 } ); - // For kind 1 events, fetch kind 1 replies - // Also fetch kind 1 replies for any event (some apps use kind 1 for everything) - replyFilters.push({ kinds: [1], '#e': [threadId] }); - - // Fetch yak backs (kind 1244) - voice replies - replyFilters.push({ kinds: [1244], '#e': [threadId] }); - - // Fetch zap receipts (kind 9735) - replyFilters.push({ kinds: [9735], '#e': [threadId] }); - - console.log('CommentThread: Loading comments for threadId:', threadId, 'event kind:', event?.kind); - console.log('CommentThread: Filters:', replyFilters); + if (quickCacheCheck.length === 0) { + loading = true; // Only show loading if no cache + } + // Now fetch with full options - returns cached immediately, fetches fresh in background const allReplies = await nostrClient.fetchEvents( replyFilters, allRelays, - { useCache: true, cacheResults: true, timeout: 10000 } + { + useCache: true, + cacheResults: true, + timeout: 10000, + onUpdate: handleReplyUpdate + } ); - - console.log('CommentThread: Fetched', allReplies.length, 'replies'); // Filter to only replies that reference the root const rootReplies = allReplies.filter(reply => referencesRoot(reply)); - - console.log('CommentThread: Root replies:', rootReplies.length); // Separate by type comments = rootReplies.filter(e => e.kind === 1111); @@ -202,20 +281,66 @@ yakBacks = rootReplies.filter(e => e.kind === 1244); zapReceipts = rootReplies.filter(e => e.kind === 9735); - console.log('CommentThread: Separated - comments:', comments.length, 'kind1Replies:', kind1Replies.length, 'yakBacks:', yakBacks.length, 'zapReceipts:', zapReceipts.length); + loading = false; // Hide loading now that we have data (cached or fresh) // Recursively fetch all nested replies (non-blocking - let it run in background) - fetchNestedReplies().catch((error) => { + fetchNestedReplies().then(() => { + subscribeToNestedReplies(); + }).catch((error) => { console.error('Error fetching nested replies:', error); + subscribeToNestedReplies(); }); } catch (error) { console.error('Error loading comments:', error); - } finally { loading = false; } } + function subscribeToNestedReplies() { + // Prevent duplicate subscriptions + if (nestedSubscriptionActive) { + return; + } + + // Subscribe to replies to any existing comments/replies + const allRelays = relayManager.getProfileReadRelays(); + const allReplyIds = new Set([ + ...comments.map(c => c.id), + ...kind1Replies.map(r => r.id), + ...yakBacks.map(y => y.id), + ...zapReceipts.map(z => z.id) + ]); + + if (allReplyIds.size === 0) { + return; + } + + nestedSubscriptionActive = true; + + // Use a single subscription that covers all reply IDs + const nestedFilters: any[] = [ + { kinds: [1111], '#e': Array.from(allReplyIds) }, + { kinds: [1111], '#E': Array.from(allReplyIds) }, + { kinds: [1], '#e': Array.from(allReplyIds) }, + { kinds: [1244], '#e': Array.from(allReplyIds) }, + { kinds: [9735], '#e': Array.from(allReplyIds) } + ]; + + nostrClient.fetchEvents( + nestedFilters, + allRelays, + { + useCache: true, + cacheResults: true, + onUpdate: handleReplyUpdate + } + ).catch(error => { + console.error('Error subscribing to nested replies:', error); + nestedSubscriptionActive = false; + }); + } + async function fetchNestedReplies() { // Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache const allRelays = relayManager.getProfileReadRelays(); @@ -309,8 +434,6 @@ // Second pass: determine parent-child relationships for (const item of items) { 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) { if (parentId === threadId) { // This is a direct reply to the root OP @@ -318,23 +441,19 @@ 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)'); } } else { // No parent - treat as root item (direct reply without parent tag) rootItems.push(item); - console.log('CommentThread: Added to rootItems (no parent)'); } } @@ -387,10 +506,7 @@ ...yakBacks.map(y => ({ event: y, type: 'yak' as const })), ...zapReceipts.map(z => ({ event: z, type: 'zap' as const })) ]; - 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; + return sortThreadItems(items); } function handleReply(replyEvent: NostrEvent) { @@ -436,33 +552,31 @@ // Fetch zap receipts (kind 9735) replyFilters.push({ kinds: [9735], '#e': [threadId] }); - console.log('CommentThread: Reloading comments after publish for threadId:', threadId); - // Don't use cache when reloading after publishing - we want fresh data const allReplies = await nostrClient.fetchEvents( replyFilters, allRelays, { useCache: false, cacheResults: true, timeout: 10000 } ); - - console.log('CommentThread: Fetched', allReplies.length, 'replies (fresh)'); // Filter to only replies that reference the root const rootReplies = allReplies.filter(reply => referencesRoot(reply)); - - console.log('CommentThread: Root replies:', rootReplies.length); // Separate by type comments = rootReplies.filter(e => e.kind === 1111); kind1Replies = rootReplies.filter(e => e.kind === 1); yakBacks = rootReplies.filter(e => e.kind === 1244); 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 (non-blocking - let it run in background) - fetchNestedReplies().catch((error) => { + fetchNestedReplies().then(() => { + // After fetching nested replies, set up a single persistent subscription + // This subscription will handle all future updates for nested replies + subscribeToNestedReplies(); + }).catch((error) => { console.error('Error fetching nested replies:', error); + // Still set up subscription even if fetch fails + subscribeToNestedReplies(); }); } catch (error) { diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index dee3aa2..72a7441 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -116,6 +116,7 @@ try { const config = nostrClient.getConfig(); const threshold = config.zapThreshold; + const zapRelays = relayManager.getZapReceiptReadRelays(); const filters = [{ kinds: [9735], @@ -124,8 +125,41 @@ const receipts = await nostrClient.fetchEvents( filters, - [...config.defaultRelays], - { useCache: true, cacheResults: true } + zapRelays, + { + useCache: true, + cacheResults: true, + onUpdate: (updated: NostrEvent[]) => { + // Recalculate zap count when new receipts arrive + const validReceipts = updated.filter((receipt) => { + const amountTag = receipt.tags.find((t) => t[0] === 'amount'); + if (amountTag && amountTag[1]) { + const amount = parseInt(amountTag[1], 10); + return !isNaN(amount) && amount >= threshold; + } + return false; + }); + + // Get all receipts for this post (including cached ones) + nostrClient.fetchEvents( + filters, + zapRelays, + { useCache: true, cacheResults: false } + ).then(allReceipts => { + const allValidReceipts = allReceipts.filter((receipt) => { + const amountTag = receipt.tags.find((t) => t[0] === 'amount'); + if (amountTag && amountTag[1]) { + const amount = parseInt(amountTag[1], 10); + return !isNaN(amount) && amount >= threshold; + } + return false; + }); + zapCount = allValidReceipts.length; + }).catch(error => { + console.error('Error recalculating zap count:', error); + }); + } + } ); // Filter by threshold and count @@ -393,9 +427,6 @@ {#if getClientName()} via {getClientName()} {/if} - {#if isReply()} - ↳ Reply - {/if} {#if post.kind === 11} {@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])} {#if topics.length === 0} @@ -479,6 +510,7 @@ .post-actions { padding-top: 0.5rem; + padding-right: 6rem; /* Reserve space for kind badge */ border-top: 1px solid var(--fog-border, #e5e7eb); margin-top: 0.5rem; } @@ -532,9 +564,9 @@ bottom: 0.5rem; right: 0.5rem; display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.125rem; + flex-direction: row; + align-items: center; + gap: 0.25rem; font-size: 0.625rem; line-height: 1; color: var(--fog-text-light, #9ca3af); @@ -549,7 +581,7 @@ } .kind-description { - font-size: 0.5rem; + font-size: 0.625rem; opacity: 0.8; } diff --git a/src/lib/modules/feed/Reply.svelte b/src/lib/modules/feed/Reply.svelte index 7647abd..f2130c6 100644 --- a/src/lib/modules/feed/Reply.svelte +++ b/src/lib/modules/feed/Reply.svelte @@ -90,7 +90,6 @@ {#if getClientName()} via {getClientName()} {/if} - ↳ Reply
@@ -150,6 +149,7 @@ .reply-actions { padding-top: 0.5rem; + padding-right: 6rem; /* Reserve space for kind badge */ border-top: 1px solid var(--fog-border, #e5e7eb); margin-top: 0.5rem; } @@ -191,9 +191,9 @@ bottom: 0.5rem; right: 0.5rem; display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.125rem; + flex-direction: row; + align-items: center; + gap: 0.25rem; font-size: 0.625rem; line-height: 1; color: var(--fog-text-light, #9ca3af); @@ -208,7 +208,7 @@ } .kind-description { - font-size: 0.5rem; + font-size: 0.625rem; opacity: 0.8; } diff --git a/src/lib/modules/feed/ThreadDrawer.svelte b/src/lib/modules/feed/ThreadDrawer.svelte index 4cddeb5..15d7644 100644 --- a/src/lib/modules/feed/ThreadDrawer.svelte +++ b/src/lib/modules/feed/ThreadDrawer.svelte @@ -16,23 +16,34 @@ let loading = $state(false); let rootEvent = $state(null); + let rootReactions = $state([]); // Reactions for the root event - // Load root event when drawer opens + // Load root event and subscribe to updates when drawer opens $effect(() => { if (isOpen && opEvent) { // Hide main page scrollbar when drawer is open + const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; - loadRootEvent(); + + loadRootEvent().then(() => { + // Only subscribe after rootEvent is loaded + if (rootEvent) { + subscribeToUpdates(); + } + }); + + // Cleanup on close + return () => { + document.body.style.overflow = originalOverflow; + rootEvent = null; + rootReactions = []; + }; } else { // Reset when closed and restore scrollbar document.body.style.overflow = ''; rootEvent = null; + rootReactions = []; } - - // Cleanup on unmount - return () => { - document.body.style.overflow = ''; - }; }); /** @@ -98,17 +109,142 @@ async function loadRootEvent() { if (!opEvent) return; + // Always set loading initially to prevent empty panel loading = true; + try { // Find the root OP event by traversing up the chain rootEvent = await findRootEvent(opEvent); + + if (!rootEvent) { + // Fallback to opEvent if root not found + rootEvent = opEvent; + } + + // Try to load reactions from cache first + const reactionRelays = relayManager.getProfileReadRelays(); + try { + const cachedReactions = await nostrClient.fetchEvents( + [{ kinds: [7], '#e': [rootEvent.id] }], + reactionRelays, + { useCache: true, cacheResults: false, timeout: 100 } + ); + if (cachedReactions.length > 0) { + rootReactions = cachedReactions; + loading = false; // Show content immediately with cached reactions + // Load fresh reactions in background + loadRootReactions(); + return; + } + } catch (error) { + // Cache check failed, continue to full load + } + + // No cached reactions - load fresh + await loadRootReactions(); + loading = false; } catch (error) { console.error('Error loading root event:', error); - } finally { + // Ensure we have at least the opEvent to show + if (!rootEvent && opEvent) { + rootEvent = opEvent; + } loading = false; } } + async function loadRootReactions() { + if (!rootEvent) return; + + try { + const reactionRelays = relayManager.getProfileReadRelays(); + const initialReactions = await nostrClient.fetchEvents( + [{ kinds: [7], '#e': [rootEvent.id] }], + reactionRelays, + { useCache: true, cacheResults: true } + ); + rootReactions = initialReactions; + } catch (error) { + console.error('Error loading root reactions:', error); + } + } + + function subscribeToUpdates() { + if (!rootEvent) return; + + const reactionRelays = relayManager.getProfileReadRelays(); + const commentRelays = relayManager.getCommentReadRelays(); + const zapRelays = relayManager.getZapReceiptReadRelays(); + + // Subscribe to reactions for the root event + nostrClient.fetchEvents( + [{ kinds: [7], '#e': [rootEvent.id] }], + reactionRelays, + { + useCache: true, + cacheResults: true, + onUpdate: (updated: NostrEvent[]) => { + // Batch updates to prevent flickering + requestAnimationFrame(() => { + // Add new reactions and update existing ones + const existingIds = new Set(rootReactions.map(r => r.id)); + const hasNew = updated.some(r => !existingIds.has(r.id)); + + if (hasNew) { + // Only update if there are actual changes + const updatedMap = new Map(rootReactions.map(r => [r.id, r])); + for (const reaction of updated) { + updatedMap.set(reaction.id, reaction); + } + rootReactions = Array.from(updatedMap.values()); + } + }); + } + } + ).catch(error => { + console.error('Error subscribing to reactions:', error); + }); + + // Subscribe to zap receipts for the root event + nostrClient.fetchEvents( + [{ kinds: [9735], '#e': [rootEvent.id] }], + zapRelays, + { + useCache: true, + cacheResults: true, + onUpdate: (updated: NostrEvent[]) => { + // Zap receipts are handled by FeedPost's internal subscription + // This subscription ensures we get updates + } + } + ).catch(error => { + console.error('Error subscribing to zap receipts:', error); + }); + + // Subscribe to comments/replies for the thread + // CommentThread will handle its own updates, but we can also subscribe here + nostrClient.fetchEvents( + [ + { kinds: [1111], '#e': [rootEvent.id] }, + { kinds: [1111], '#E': [rootEvent.id] }, + { kinds: [1], '#e': [rootEvent.id] }, + { kinds: [1244], '#e': [rootEvent.id] }, + { kinds: [9735], '#e': [rootEvent.id] } + ], + commentRelays, + { + useCache: true, + cacheResults: true, + onUpdate: (updated: NostrEvent[]) => { + // CommentThread will handle these updates via its own subscription + // This ensures we get updates even if CommentThread hasn't loaded yet + } + } + ).catch(error => { + console.error('Error subscribing to comments:', error); + }); + } + function handleBackdropClick(e: MouseEvent) { if (e.target === e.currentTarget) { onClose(); @@ -120,6 +256,25 @@ onClose(); } } + + function handleBackdropWheel(e: WheelEvent) { + // Only prevent scrolling if the event target is the backdrop itself + // Allow scrolling on the drawer content + const target = e.target as HTMLElement; + if (target && target.classList.contains('drawer-backdrop')) { + e.preventDefault(); + e.stopPropagation(); + } + } + + function handleBackdropTouchMove(e: TouchEvent) { + // Only prevent touch scrolling if the event target is the backdrop itself + const target = e.target as HTMLElement; + if (target && target.classList.contains('drawer-backdrop')) { + e.preventDefault(); + e.stopPropagation(); + } + } @@ -128,6 +283,8 @@
{ if (e.key === 'Escape') { onClose(); @@ -147,18 +304,20 @@
- {#if loading} + {#if loading && !rootEvent}

Loading thread...

{:else if rootEvent}
- +
+ {:else} +

Unable to load thread.

{/if}
@@ -176,6 +335,13 @@ z-index: 1000; display: flex; justify-content: flex-end; + overflow: hidden; + overscroll-behavior: contain; + } + + /* Allow scrolling on drawer content */ + .drawer-content { + touch-action: auto; } :global(.dark) .drawer-backdrop { diff --git a/src/lib/modules/feed/ZapReceiptReply.svelte b/src/lib/modules/feed/ZapReceiptReply.svelte index 45159f3..1253c57 100644 --- a/src/lib/modules/feed/ZapReceiptReply.svelte +++ b/src/lib/modules/feed/ZapReceiptReply.svelte @@ -156,6 +156,7 @@ .zap-actions { padding-top: 0.5rem; + padding-right: 6rem; /* Reserve space for kind badge */ border-top: 1px solid var(--fog-border, #e5e7eb); margin-top: 0.5rem; } @@ -188,9 +189,9 @@ bottom: 0.5rem; right: 0.5rem; display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.125rem; + flex-direction: row; + align-items: center; + gap: 0.25rem; font-size: 0.625rem; line-height: 1; color: var(--fog-text-light, #9ca3af); @@ -205,7 +206,7 @@ } .kind-description { - font-size: 0.5rem; + font-size: 0.625rem; opacity: 0.8; } diff --git a/src/lib/modules/profiles/PaymentAddresses.svelte b/src/lib/modules/profiles/PaymentAddresses.svelte index 2266162..0122366 100644 --- a/src/lib/modules/profiles/PaymentAddresses.svelte +++ b/src/lib/modules/profiles/PaymentAddresses.svelte @@ -125,6 +125,9 @@ padding: 0.5rem; background: var(--fog-highlight, #f3f4f6); border-radius: 0.25rem; + flex-wrap: wrap; + overflow-wrap: break-word; + word-break: break-word; } :global(.dark) .address-item { @@ -134,5 +137,7 @@ code { font-family: monospace; word-break: break-all; + overflow-wrap: break-word; + max-width: 100%; } diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 46cf6a2..0e3b4a9 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -430,6 +430,45 @@ object-fit: cover; } + .profile-header { + overflow-wrap: break-word; + word-break: break-word; + } + + .profile-header h1 { + overflow-wrap: break-word; + word-break: break-word; + } + + .profile-header p { + overflow-wrap: break-word; + word-break: break-word; + } + + .profile-header .websites { + overflow-wrap: break-word; + word-break: break-word; + } + + .profile-header .websites a { + display: inline-block; + max-width: 100%; + overflow-wrap: break-word; + word-break: break-word; + } + + .profile-header .nip05 { + overflow-wrap: break-word; + word-break: break-word; + } + + .profile-header .nip05 span { + display: inline-block; + max-width: 100%; + overflow-wrap: break-word; + word-break: break-word; + } + .nip05-valid { color: #60a5fa; margin-left: 0.25rem; diff --git a/src/lib/modules/threads/ThreadCard.svelte b/src/lib/modules/threads/ThreadCard.svelte index 55972f3..64140f1 100644 --- a/src/lib/modules/threads/ThreadCard.svelte +++ b/src/lib/modules/threads/ThreadCard.svelte @@ -224,7 +224,7 @@ {/if} -
+
{#if loadingStats} Loading stats... @@ -298,9 +298,9 @@ bottom: 0.5rem; right: 0.5rem; display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.125rem; + flex-direction: row; + align-items: center; + gap: 0.25rem; font-size: 0.625rem; line-height: 1; color: var(--fog-text-light, #9ca3af); @@ -315,7 +315,11 @@ } .kind-description { - font-size: 0.5rem; + font-size: 0.625rem; opacity: 0.8; } + + .thread-stats { + padding-right: 6rem; /* Reserve space for kind badge */ + } diff --git a/src/lib/services/nostr/nip21-parser.ts b/src/lib/services/nostr/nip21-parser.ts index c6bf8f0..dfef45e 100644 --- a/src/lib/services/nostr/nip21-parser.ts +++ b/src/lib/services/nostr/nip21-parser.ts @@ -60,15 +60,15 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { /** * Find all NIP-21 URIs in text - * Also finds plain bech32 mentions (npub1..., note1..., etc.) without nostr: prefix + * Only matches nostr: prefixed URIs (nostr:npub, nostr:note, etc.) + * Plain bech32 strings without "nostr:" prefix are NOT matched */ export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> { const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = []; const seenPositions = new Set(); // Track positions to avoid duplicates - const seenEntities = new Map(); // Track entities to prefer nostr: versions - // First, match nostr: URIs (case-insensitive) - these take priority - // Also match hex event IDs (64 hex characters) as nostr:hexID + // Only match nostr: URIs (case-insensitive) + // Match hex event IDs (64 hex characters) as nostr:hexID const nostrUriRegex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi; let match; @@ -79,9 +79,6 @@ export function findNIP21Links(text: string): Array<{ uri: string; start: number const key = `${match.index}-${match.index + uri.length}`; if (!seenPositions.has(key)) { seenPositions.add(key); - // Extract the entity identifier (without nostr: prefix) - const entityId = uri.slice(6); // Remove 'nostr:' prefix - seenEntities.set(entityId.toLowerCase(), { start: match.index, end: match.index + uri.length }); links.push({ uri, start: match.index, @@ -92,74 +89,6 @@ export function findNIP21Links(text: string): Array<{ uri: string; start: number } } - // Also match plain bech32 mentions (npub1..., note1..., nevent1..., naddr1..., nprofile1...) - // and hex event IDs (64 hex characters) without nostr: prefix - // Use word boundaries to avoid matching partial strings - // BUT skip if we already found a nostr: version of the same entity - const bech32Regex = /\b((npub|note|nevent|naddr|nprofile)1[a-z0-9]{58,})\b/gi; - while ((match = bech32Regex.exec(text)) !== null) { - const bech32String = match[1]; - const key = `${match.index}-${match.index + bech32String.length}`; - - // Skip if this position overlaps with a nostr: URI we already found - if (seenPositions.has(key)) continue; - - // Skip if we already found a nostr: version of this entity - const existing = seenEntities.get(bech32String.toLowerCase()); - if (existing) { - // Check if positions overlap - if (!(match.index >= existing.end || match.index + bech32String.length <= existing.start)) { - continue; // Overlaps with nostr: version, skip - } - } - - seenPositions.add(key); - // Create a nostr: URI for parsing - const uri = `nostr:${bech32String}`; - const parsed = parseNIP21(uri); - if (parsed) { - links.push({ - uri: bech32String, // Store without nostr: prefix for display - start: match.index, - end: match.index + bech32String.length, - parsed - }); - } - } - - // Match hex event IDs (64 hex characters) without nostr: prefix - // BUT skip if we already found a nostr: version - const hexIdRegex = /\b([0-9a-f]{64})\b/gi; - while ((match = hexIdRegex.exec(text)) !== null) { - const hexId = match[1]; - const key = `${match.index}-${match.index + hexId.length}`; - - // Skip if this position overlaps with a nostr: URI we already found - if (seenPositions.has(key)) continue; - - // Skip if we already found a nostr: version of this hex ID - const existing = seenEntities.get(hexId.toLowerCase()); - if (existing) { - // Check if positions overlap - if (!(match.index >= existing.end || match.index + hexId.length <= existing.start)) { - continue; // Overlaps with nostr: version, skip - } - } - - seenPositions.add(key); - // Create a nostr: URI for parsing - const uri = `nostr:${hexId}`; - const parsed = parseNIP21(uri); - if (parsed) { - links.push({ - uri: hexId, // Store without nostr: prefix - start: match.index, - end: match.index + hexId.length, - parsed - }); - } - } - // Sort by start position links.sort((a, b) => a.start - b.start); diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 2e61f4e..f136776 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -250,7 +250,7 @@ class NostrClient { client.addToCache(event); onEvent(event, url); } catch (err) { - // Silently handle errors + // Silently handle errors - connection may be closed } }, oneose: () => { @@ -258,7 +258,7 @@ class NostrClient { if (!client.relays.has(url)) return; onEose?.(url); } catch (err) { - // Silently handle errors + // Silently handle errors - connection may be closed } } }); @@ -272,7 +272,15 @@ class NostrClient { unsubscribe(subId: string): void { for (const [key, { sub }] of this.subscriptions.entries()) { if (key.endsWith(`_${subId}`)) { - sub.close(); + try { + // Try to close subscription - may fail if connection already closed + if (sub && typeof sub.close === 'function') { + sub.close(); + } + } catch (error) { + // Silently handle errors when closing subscriptions + // Connection may already be closed, which is fine + } this.subscriptions.delete(key); } } @@ -355,14 +363,22 @@ class NostrClient { const client = this; const sub = relay.subscribe(filters, { onevent: (event: NostrEvent) => { - if (!client.relays.has(relayUrl)) return; - if (shouldHideEvent(event)) return; - if (client.shouldFilterZapReceipt(event)) return; - events.set(event.id, event); - client.addToCache(event); + try { + if (!client.relays.has(relayUrl)) return; + if (shouldHideEvent(event)) return; + if (client.shouldFilterZapReceipt(event)) return; + events.set(event.id, event); + client.addToCache(event); + } catch (error) { + // Silently handle errors - connection may be closed + } }, oneose: () => { - if (!resolved) finish(); + try { + if (!resolved) finish(); + } catch (error) { + // Silently handle errors - connection may be closed + } } });