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 @@ @@ -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 @@ @@ -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 @@ @@ -179,7 +183,7 @@
}
.kind-description {
font-size: 0.5rem;
font-size: 0.625rem;
opacity: 0.8;
}
</style>

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

@ -22,6 +22,8 @@ @@ -22,6 +22,8 @@
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
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 rootKind = $derived(event?.kind || null);
@ -33,6 +35,13 @@ @@ -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 @@ @@ -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,74 +161,186 @@ @@ -148,74 +161,186 @@
return !!eTag;
}
async function loadComments() {
if (!threadId) {
loading = false;
function handleReplyUpdate(updated: NostrEvent[]) {
// Prevent recursive calls
if (isProcessingUpdate) {
return;
}
loading = true;
// Batch updates to prevent flickering
requestAnimationFrame(() => {
isProcessingUpdate = true;
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();
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)
);
const replyFilters: any[] = [];
if (!isReplyToRoot && !isReplyToExisting) {
continue;
}
// 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)
);
// 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;
}
}
// 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] });
// 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;
}
});
}
// Fetch yak backs (kind 1244) - voice replies
replyFilters.push({ kinds: [1244], '#e': [threadId] });
async function loadComments() {
if (!threadId) {
loading = false;
return;
}
// Fetch zap receipts (kind 9735)
replyFilters.push({ kinds: [9735], '#e': [threadId] });
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 {
// 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);
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);
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);
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 @@ @@ -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 @@ @@ -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 @@ @@ -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,8 +552,6 @@ @@ -436,8 +552,6 @@
// 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,
@ -445,24 +559,24 @@ @@ -445,24 +559,24 @@
{ 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) {

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

@ -116,6 +116,7 @@ @@ -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 @@ @@ -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 @@ @@ -393,9 +427,6 @@
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/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}
{@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])}
{#if topics.length === 0}
@ -479,6 +510,7 @@ @@ -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 @@ @@ -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 @@ @@ -549,7 +581,7 @@
}
.kind-description {
font-size: 0.5rem;
font-size: 0.625rem;
opacity: 0.8;
}

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

@ -90,7 +90,6 @@ @@ -90,7 +90,6 @@
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span>
</div>
<div class="reply-content mb-2">
@ -150,6 +149,7 @@ @@ -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 @@ @@ -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 @@ @@ -208,7 +208,7 @@
}
.kind-description {
font-size: 0.5rem;
font-size: 0.625rem;
opacity: 0.8;
}
</style>

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

@ -16,23 +16,34 @@ @@ -16,23 +16,34 @@
let loading = $state(false);
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(() => {
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 @@ @@ -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 @@ @@ -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();
}
}
</script>
<svelte:window onkeydown={handleEscape} />
@ -128,6 +283,8 @@ @@ -128,6 +283,8 @@
<div
class="drawer-backdrop"
onclick={handleBackdropClick}
onwheel={handleBackdropWheel}
ontouchmove={handleBackdropTouchMove}
onkeydown={(e) => {
if (e.key === 'Escape') {
onClose();
@ -147,18 +304,20 @@ @@ -147,18 +304,20 @@
</div>
<div class="drawer-content">
{#if loading}
{#if loading && !rootEvent}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p>
{:else if rootEvent}
<!-- Display the root OP event -->
<div class="op-section">
<FeedPost post={rootEvent} />
<FeedPost post={rootEvent} reactions={rootReactions} />
</div>
<!-- Display all replies using CommentThread -->
<div class="replies-section">
<CommentThread threadId={rootEvent.id} event={rootEvent} />
</div>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Unable to load thread.</p>
{/if}
</div>
</div>
@ -176,6 +335,13 @@ @@ -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 {

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

@ -156,6 +156,7 @@ @@ -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 @@ @@ -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 @@ @@ -205,7 +206,7 @@
}
.kind-description {
font-size: 0.5rem;
font-size: 0.625rem;
opacity: 0.8;
}
</style>

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

@ -125,6 +125,9 @@ @@ -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 @@ @@ -134,5 +137,7 @@
code {
font-family: monospace;
word-break: break-all;
overflow-wrap: break-word;
max-width: 100%;
}
</style>

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

@ -430,6 +430,45 @@ @@ -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;

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

@ -224,7 +224,7 @@ @@ -224,7 +224,7 @@
</div>
{/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">
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
@ -298,9 +298,9 @@ @@ -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 @@ @@ -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 */
}
</style>

79
src/lib/services/nostr/nip21-parser.ts

@ -60,15 +60,15 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { @@ -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<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
// 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 @@ -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 @@ -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);

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

@ -250,7 +250,7 @@ class NostrClient { @@ -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 { @@ -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 { @@ -272,7 +272,15 @@ class NostrClient {
unsubscribe(subId: string): void {
for (const [key, { sub }] of this.subscriptions.entries()) {
if (key.endsWith(`_${subId}`)) {
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 { @@ -355,14 +363,22 @@ class NostrClient {
const client = this;
const sub = relay.subscribe(filters, {
onevent: (event: NostrEvent) => {
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: () => {
try {
if (!resolved) finish();
} catch (error) {
// Silently handle errors - connection may be closed
}
}
});

Loading…
Cancel
Save