Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
bd0e76fe63
  1. 12
      src/lib/modules/comments/Comment.svelte
  2. 216
      src/lib/modules/comments/CommentThread.svelte
  3. 50
      src/lib/modules/feed/FeedPost.svelte
  4. 10
      src/lib/modules/feed/Reply.svelte
  5. 186
      src/lib/modules/feed/ThreadDrawer.svelte
  6. 9
      src/lib/modules/feed/ZapReceiptReply.svelte
  7. 5
      src/lib/modules/profiles/PaymentAddresses.svelte
  8. 39
      src/lib/modules/profiles/ProfilePage.svelte
  9. 14
      src/lib/modules/threads/ThreadCard.svelte
  10. 79
      src/lib/services/nostr/nip21-parser.ts
  11. 20
      src/lib/services/nostr/nostr-client.ts

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

@ -138,6 +138,10 @@
line-height: 1.6; line-height: 1.6;
} }
.comment-actions {
padding-right: 6rem; /* Reserve space for kind badge */
}
.card-content { .card-content {
max-height: 500px; max-height: 500px;
overflow: hidden; overflow: hidden;
@ -162,9 +166,9 @@
bottom: 0.5rem; bottom: 0.5rem;
right: 0.5rem; right: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-end; align-items: center;
gap: 0.125rem; gap: 0.25rem;
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #9ca3af); color: var(--fog-text-light, #9ca3af);
@ -179,7 +183,7 @@
} }
.kind-description { .kind-description {
font-size: 0.5rem; font-size: 0.625rem;
opacity: 0.8; opacity: 0.8;
} }
</style> </style>

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

@ -22,6 +22,8 @@
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 let loadingPromise: Promise<void> | 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 isKind1 = $derived(event?.kind === 1);
const rootKind = $derived(event?.kind || null); const rootKind = $derived(event?.kind || null);
@ -33,6 +35,13 @@
// Reload comments when threadId changes // Reload comments when threadId changes
$effect(() => { $effect(() => {
if (!threadId) { if (!threadId) {
// Reset state when threadId is cleared
comments = [];
kind1Replies = [];
yakBacks = [];
zapReceipts = [];
nestedSubscriptionActive = false;
isProcessingUpdate = false;
return; return;
} }
@ -41,6 +50,10 @@
return; return;
} }
// Reset subscription flag when loading new thread
nestedSubscriptionActive = false;
isProcessingUpdate = false;
// Load comments - filters will adapt based on whether event is available // Load comments - filters will adapt based on whether event is available
// Ensure nostrClient is initialized first // Ensure nostrClient is initialized first
loadingPromise = nostrClient.initialize().then(() => { loadingPromise = nostrClient.initialize().then(() => {
@ -148,74 +161,186 @@
return !!eTag; return !!eTag;
} }
async function loadComments() { function handleReplyUpdate(updated: NostrEvent[]) {
if (!threadId) { // Prevent recursive calls
loading = false; if (isProcessingUpdate) {
return; return;
} }
loading = true; // Batch updates to prevent flickering
requestAnimationFrame(() => {
isProcessingUpdate = true;
try { try {
// Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache let hasNewReplies = false;
// getProfileReadRelays() includes: defaultRelays + profileRelays + user inbox (which includes local relays from kind 10432) const commentsMap = new Map(comments.map(c => [c.id, c]));
const allRelays = relayManager.getProfileReadRelays(); 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)
);
const replyFilters: any[] = []; if (!isReplyToRoot && !isReplyToExisting) {
continue;
}
// Always fetch kind 1111 comments - check both e and E tags, and a and A tags // Add the reply to the appropriate map
replyFilters.push( if (reply.kind === 1111) {
{ kinds: [1111], '#e': [threadId] }, // Lowercase e tag commentsMap.set(reply.id, reply);
{ kinds: [1111], '#E': [threadId] }, // Uppercase E tag (NIP-22) hasNewReplies = true;
{ kinds: [1111], '#a': [threadId] }, // Lowercase a tag (some clients use wrong tags) } else if (reply.kind === 1) {
{ kinds: [1111], '#A': [threadId] } // Uppercase A tag (NIP-22 for addressable events) 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;
}
}
// For kind 1 events, fetch kind 1 replies // Only update state if we have new replies
// Also fetch kind 1 replies for any event (some apps use kind 1 for everything) if (hasNewReplies) {
replyFilters.push({ kinds: [1], '#e': [threadId] }); comments = Array.from(commentsMap.values());
kind1Replies = Array.from(kind1RepliesMap.values());
yakBacks = Array.from(yakBacksMap.values());
zapReceipts = Array.from(zapReceiptsMap.values());
}
} finally {
isProcessingUpdate = false;
}
});
}
// Fetch yak backs (kind 1244) - voice replies async function loadComments() {
replyFilters.push({ kinds: [1244], '#e': [threadId] }); if (!threadId) {
loading = false;
return;
}
// Fetch zap receipts (kind 9735) const allRelays = relayManager.getProfileReadRelays();
replyFilters.push({ kinds: [9735], '#e': [threadId] }); 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 {
// Quick cache check - if we have cache, don't show loading
const quickCacheCheck = await nostrClient.fetchEvents(
replyFilters,
allRelays,
{ useCache: true, cacheResults: false, timeout: 50 }
);
console.log('CommentThread: Loading comments for threadId:', threadId, 'event kind:', event?.kind); if (quickCacheCheck.length === 0) {
console.log('CommentThread: Filters:', replyFilters); 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( const allReplies = await nostrClient.fetchEvents(
replyFilters, replyFilters,
allRelays, 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 // 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); loading = false; // Hide loading now that we have data (cached or fresh)
// Recursively fetch all nested replies (non-blocking - let it run in background) // 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); console.error('Error fetching nested replies:', error);
subscribeToNestedReplies();
}); });
} catch (error) { } catch (error) {
console.error('Error loading comments:', error); console.error('Error loading comments:', error);
} finally {
loading = false; 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() { async function fetchNestedReplies() {
// Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache // Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache
const allRelays = relayManager.getProfileReadRelays(); const allRelays = relayManager.getProfileReadRelays();
@ -309,8 +434,6 @@
// 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) { if (parentId) {
if (parentId === threadId) { if (parentId === threadId) {
// This is a direct reply to the root OP // This is a direct reply to the root OP
@ -318,23 +441,19 @@
replyMap.set(threadId, []); replyMap.set(threadId, []);
} }
replyMap.get(threadId)!.push(item.event.id); 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)) { } else if (allEventIds.has(parentId)) {
// This is a reply to another reply // This is a reply to another reply
if (!replyMap.has(parentId)) { if (!replyMap.has(parentId)) {
replyMap.set(parentId, []); replyMap.set(parentId, []);
} }
replyMap.get(parentId)!.push(item.event.id); replyMap.get(parentId)!.push(item.event.id);
console.log('CommentThread: Added to replyMap for parent:', parentId.slice(0, 8));
} else { } else {
// Parent not found - treat as root item (orphaned reply) // Parent not found - treat as root item (orphaned reply)
rootItems.push(item); rootItems.push(item);
console.log('CommentThread: Added to rootItems (orphaned)');
} }
} else { } else {
// No parent - treat as root item (direct reply without parent tag) // 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)');
} }
} }
@ -387,10 +506,7 @@
...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 }))
]; ];
console.log('CommentThread: getThreadItems - items before sort:', items.length, items.map(i => ({ type: i.type, id: i.event.id.slice(0, 8) }))); return sortThreadItems(items);
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) {
@ -436,8 +552,6 @@
// 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: Reloading comments after publish for threadId:', threadId);
// Don't use cache when reloading after publishing - we want fresh data // Don't use cache when reloading after publishing - we want fresh data
const allReplies = await nostrClient.fetchEvents( const allReplies = await nostrClient.fetchEvents(
replyFilters, replyFilters,
@ -445,24 +559,24 @@
{ useCache: false, cacheResults: true, timeout: 10000 } { useCache: false, cacheResults: true, timeout: 10000 }
); );
console.log('CommentThread: Fetched', allReplies.length, 'replies (fresh)');
// 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 (non-blocking - let it run in background) // 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); console.error('Error fetching nested replies:', error);
// Still set up subscription even if fetch fails
subscribeToNestedReplies();
}); });
} catch (error) { } catch (error) {

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

@ -116,6 +116,7 @@
try { try {
const config = nostrClient.getConfig(); const config = nostrClient.getConfig();
const threshold = config.zapThreshold; const threshold = config.zapThreshold;
const zapRelays = relayManager.getZapReceiptReadRelays();
const filters = [{ const filters = [{
kinds: [9735], kinds: [9735],
@ -124,8 +125,41 @@
const receipts = await nostrClient.fetchEvents( const receipts = await nostrClient.fetchEvents(
filters, filters,
[...config.defaultRelays], zapRelays,
{ useCache: true, cacheResults: true } {
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 // Filter by threshold and count
@ -393,9 +427,6 @@
{#if getClientName()} {#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if} {/if}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span>
{/if}
{#if post.kind === 11} {#if post.kind === 11}
{@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])} {@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])}
{#if topics.length === 0} {#if topics.length === 0}
@ -479,6 +510,7 @@
.post-actions { .post-actions {
padding-top: 0.5rem; padding-top: 0.5rem;
padding-right: 6rem; /* Reserve space for kind badge */
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -532,9 +564,9 @@
bottom: 0.5rem; bottom: 0.5rem;
right: 0.5rem; right: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-end; align-items: center;
gap: 0.125rem; gap: 0.25rem;
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #9ca3af); color: var(--fog-text-light, #9ca3af);
@ -549,7 +581,7 @@
} }
.kind-description { .kind-description {
font-size: 0.5rem; font-size: 0.625rem;
opacity: 0.8; opacity: 0.8;
} }

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

@ -90,7 +90,6 @@
{#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}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
</div> </div>
<div class="reply-content mb-2"> <div class="reply-content mb-2">
@ -150,6 +149,7 @@
.reply-actions { .reply-actions {
padding-top: 0.5rem; padding-top: 0.5rem;
padding-right: 6rem; /* Reserve space for kind badge */
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -191,9 +191,9 @@
bottom: 0.5rem; bottom: 0.5rem;
right: 0.5rem; right: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-end; align-items: center;
gap: 0.125rem; gap: 0.25rem;
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #9ca3af); color: var(--fog-text-light, #9ca3af);
@ -208,7 +208,7 @@
} }
.kind-description { .kind-description {
font-size: 0.5rem; font-size: 0.625rem;
opacity: 0.8; opacity: 0.8;
} }
</style> </style>

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

@ -16,23 +16,34 @@
let loading = $state(false); let loading = $state(false);
let rootEvent = $state<NostrEvent | null>(null); let rootEvent = $state<NostrEvent | null>(null);
let rootReactions = $state<NostrEvent[]>([]); // Reactions for the root event
// Load root event when drawer opens // Load root event and subscribe to updates when drawer opens
$effect(() => { $effect(() => {
if (isOpen && opEvent) { if (isOpen && opEvent) {
// Hide main page scrollbar when drawer is open // Hide main page scrollbar when drawer is open
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; 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 { } else {
// Reset when closed and restore scrollbar // Reset when closed and restore scrollbar
document.body.style.overflow = ''; document.body.style.overflow = '';
rootEvent = null; rootEvent = null;
rootReactions = [];
} }
// Cleanup on unmount
return () => {
document.body.style.overflow = '';
};
}); });
/** /**
@ -98,17 +109,142 @@
async function loadRootEvent() { async function loadRootEvent() {
if (!opEvent) return; if (!opEvent) return;
// Always set loading initially to prevent empty panel
loading = true; loading = true;
try { try {
// Find the root OP event by traversing up the chain // Find the root OP event by traversing up the chain
rootEvent = await findRootEvent(opEvent); 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) { } catch (error) {
console.error('Error loading root event:', 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; 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) { function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose();
@ -120,6 +256,25 @@
onClose(); 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();
}
}
</script> </script>
<svelte:window onkeydown={handleEscape} /> <svelte:window onkeydown={handleEscape} />
@ -128,6 +283,8 @@
<div <div
class="drawer-backdrop" class="drawer-backdrop"
onclick={handleBackdropClick} onclick={handleBackdropClick}
onwheel={handleBackdropWheel}
ontouchmove={handleBackdropTouchMove}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onClose(); onClose();
@ -147,18 +304,20 @@
</div> </div>
<div class="drawer-content"> <div class="drawer-content">
{#if loading} {#if loading && !rootEvent}
<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}
<!-- Display the root OP event --> <!-- Display the root OP event -->
<div class="op-section"> <div class="op-section">
<FeedPost post={rootEvent} /> <FeedPost post={rootEvent} reactions={rootReactions} />
</div> </div>
<!-- Display all 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>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Unable to load thread.</p>
{/if} {/if}
</div> </div>
</div> </div>
@ -176,6 +335,13 @@
z-index: 1000; z-index: 1000;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
overflow: hidden;
overscroll-behavior: contain;
}
/* Allow scrolling on drawer content */
.drawer-content {
touch-action: auto;
} }
:global(.dark) .drawer-backdrop { :global(.dark) .drawer-backdrop {

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

@ -156,6 +156,7 @@
.zap-actions { .zap-actions {
padding-top: 0.5rem; padding-top: 0.5rem;
padding-right: 6rem; /* Reserve space for kind badge */
border-top: 1px solid var(--fog-border, #e5e7eb); border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -188,9 +189,9 @@
bottom: 0.5rem; bottom: 0.5rem;
right: 0.5rem; right: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-end; align-items: center;
gap: 0.125rem; gap: 0.25rem;
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #9ca3af); color: var(--fog-text-light, #9ca3af);
@ -205,7 +206,7 @@
} }
.kind-description { .kind-description {
font-size: 0.5rem; font-size: 0.625rem;
opacity: 0.8; opacity: 0.8;
} }
</style> </style>

5
src/lib/modules/profiles/PaymentAddresses.svelte

@ -125,6 +125,9 @@
padding: 0.5rem; padding: 0.5rem;
background: var(--fog-highlight, #f3f4f6); background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem; border-radius: 0.25rem;
flex-wrap: wrap;
overflow-wrap: break-word;
word-break: break-word;
} }
:global(.dark) .address-item { :global(.dark) .address-item {
@ -134,5 +137,7 @@
code { code {
font-family: monospace; font-family: monospace;
word-break: break-all; word-break: break-all;
overflow-wrap: break-word;
max-width: 100%;
} }
</style> </style>

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

@ -430,6 +430,45 @@
object-fit: cover; 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 { .nip05-valid {
color: #60a5fa; color: #60a5fa;
margin-left: 0.25rem; margin-left: 0.25rem;

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

@ -224,7 +224,7 @@
</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 thread-stats">
<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>
@ -298,9 +298,9 @@
bottom: 0.5rem; bottom: 0.5rem;
right: 0.5rem; right: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-end; align-items: center;
gap: 0.125rem; gap: 0.25rem;
font-size: 0.625rem; font-size: 0.625rem;
line-height: 1; line-height: 1;
color: var(--fog-text-light, #9ca3af); color: var(--fog-text-light, #9ca3af);
@ -315,7 +315,11 @@
} }
.kind-description { .kind-description {
font-size: 0.5rem; font-size: 0.625rem;
opacity: 0.8; opacity: 0.8;
} }
.thread-stats {
padding-right: 6rem; /* Reserve space for kind badge */
}
</style> </style>

79
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 * 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 }> { 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 links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = [];
const seenPositions = new Set<string>(); // Track positions to avoid duplicates const seenPositions = new Set<string>(); // Track positions to avoid duplicates
const seenEntities = new Map<string, { start: number; end: number }>(); // Track entities to prefer nostr: versions
// First, match nostr: URIs (case-insensitive) - these take priority // Only match nostr: URIs (case-insensitive)
// Also match hex event IDs (64 hex characters) as nostr:hexID // 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; const nostrUriRegex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi;
let match; let match;
@ -79,9 +79,6 @@ export function findNIP21Links(text: string): Array<{ uri: string; start: number
const key = `${match.index}-${match.index + uri.length}`; const key = `${match.index}-${match.index + uri.length}`;
if (!seenPositions.has(key)) { if (!seenPositions.has(key)) {
seenPositions.add(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({ links.push({
uri, uri,
start: match.index, 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 // Sort by start position
links.sort((a, b) => a.start - b.start); links.sort((a, b) => a.start - b.start);

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

@ -250,7 +250,7 @@ class NostrClient {
client.addToCache(event); client.addToCache(event);
onEvent(event, url); onEvent(event, url);
} catch (err) { } catch (err) {
// Silently handle errors // Silently handle errors - connection may be closed
} }
}, },
oneose: () => { oneose: () => {
@ -258,7 +258,7 @@ class NostrClient {
if (!client.relays.has(url)) return; if (!client.relays.has(url)) return;
onEose?.(url); onEose?.(url);
} catch (err) { } catch (err) {
// Silently handle errors // Silently handle errors - connection may be closed
} }
} }
}); });
@ -272,7 +272,15 @@ class NostrClient {
unsubscribe(subId: string): void { unsubscribe(subId: string): void {
for (const [key, { sub }] of this.subscriptions.entries()) { for (const [key, { sub }] of this.subscriptions.entries()) {
if (key.endsWith(`_${subId}`)) { if (key.endsWith(`_${subId}`)) {
try {
// Try to close subscription - may fail if connection already closed
if (sub && typeof sub.close === 'function') {
sub.close(); sub.close();
}
} catch (error) {
// Silently handle errors when closing subscriptions
// Connection may already be closed, which is fine
}
this.subscriptions.delete(key); this.subscriptions.delete(key);
} }
} }
@ -355,14 +363,22 @@ class NostrClient {
const client = this; const client = this;
const sub = relay.subscribe(filters, { const sub = relay.subscribe(filters, {
onevent: (event: NostrEvent) => { onevent: (event: NostrEvent) => {
try {
if (!client.relays.has(relayUrl)) return; if (!client.relays.has(relayUrl)) return;
if (shouldHideEvent(event)) return; if (shouldHideEvent(event)) return;
if (client.shouldFilterZapReceipt(event)) return; if (client.shouldFilterZapReceipt(event)) return;
events.set(event.id, event); events.set(event.id, event);
client.addToCache(event); client.addToCache(event);
} catch (error) {
// Silently handle errors - connection may be closed
}
}, },
oneose: () => { oneose: () => {
try {
if (!resolved) finish(); if (!resolved) finish();
} catch (error) {
// Silently handle errors - connection may be closed
}
} }
}); });

Loading…
Cancel
Save