diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 1fa1936..146fb12 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -14,6 +14,56 @@ let rendered = $state(''); let containerElement: HTMLDivElement | null = $state(null); + // Process rendered HTML to add lazy loading and prevent autoplay + function processMediaElements(html: string): string { + // Add loading="lazy" to all images + html = html.replace(/]*)>/gi, (match, attrs) => { + // Don't add if already has loading attribute + if (/loading\s*=/i.test(attrs)) { + return match; + } + return ``; + }); + + // Ensure videos don't autoplay and use preload="none" + html = html.replace(/]*)>/gi, (match, attrs) => { + let newAttrs = attrs; + // Remove autoplay if present + newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' '); + // Set preload to none if not already set + if (!/preload\s*=/i.test(newAttrs)) { + newAttrs += ' preload="none"'; + } else { + newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"'); + } + // Ensure autoplay is explicitly false + if (!/autoplay\s*=/i.test(newAttrs)) { + newAttrs += ' autoplay="false"'; + } + return ``; + }); + + // Ensure audio doesn't autoplay and use preload="none" + html = html.replace(/]*)>/gi, (match, attrs) => { + let newAttrs = attrs; + // Remove autoplay if present + newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' '); + // Set preload to none if not already set + if (!/preload\s*=/i.test(newAttrs)) { + newAttrs += ' preload="none"'; + } else { + newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"'); + } + // Ensure autoplay is explicitly false + if (!/autoplay\s*=/i.test(newAttrs)) { + newAttrs += ' autoplay="false"'; + } + return ``; + }); + + return html; + } + $effect(() => { if (content) { // Process NIP-21 links before markdown parsing @@ -42,6 +92,9 @@ parseResult.then((html) => { let finalHtml = sanitizeMarkdown(html); + // Process media elements for lazy loading + finalHtml = processMediaElements(finalHtml); + // Replace placeholders with actual NIP-21 links for (const [placeholder, { uri, parsed }] of placeholders.entries()) { let replacement = ''; @@ -82,6 +135,9 @@ } else { let finalHtml = sanitizeMarkdown(parseResult); + // Process media elements for lazy loading + finalHtml = processMediaElements(finalHtml); + // Replace placeholders with actual NIP-21 links for (const [placeholder, { uri, parsed }] of placeholders.entries()) { let replacement = ''; diff --git a/src/lib/components/content/MediaAttachments.svelte b/src/lib/components/content/MediaAttachments.svelte index db3d94e..268e322 100644 --- a/src/lib/components/content/MediaAttachments.svelte +++ b/src/lib/components/content/MediaAttachments.svelte @@ -1,11 +1,16 @@ {#if coverImage} - + {#if shouldLoad(coverImage.url)} + + {:else} + + Loading image... + + {/if} {/if} @@ -158,31 +255,72 @@ {#each otherMedia as item} {#if item.type === 'image'} - + {#if shouldLoad(item.url)} + + {:else} + + Loading... + + {/if} {:else if item.type === 'video'} - - - Your browser does not support the video tag. - + {#if shouldLoad(item.url)} + + + Your browser does not support the video tag. + + {:else} + + ▶️ Video + + {/if} {:else if item.type === 'audio'} - - Your browser does not support the audio tag. - + {#if shouldLoad(item.url)} + + Your browser does not support the audio tag. + + {:else} + + 🎵 Audio + + {/if} {:else if item.type === 'file'} @@ -254,4 +392,12 @@ .file-link:hover { text-decoration: underline; } + + .media-placeholder { + border: 1px solid var(--fog-border, #e5e7eb); + } + + :global(.dark) .media-placeholder { + border-color: var(--fog-dark-border, #374151); + } diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index e7cc457..0a02491 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -123,14 +123,6 @@ {/if} {profile?.name || shortenedNpub} - {#if activityStatus && activityMessage} - - {/if} {#if status} ({status}) {/if} diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index 811fb53..2ebfc5e 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -2,21 +2,27 @@ import Comment from './Comment.svelte'; import CommentForm from './CommentForm.svelte'; import ZapReceiptReply from '../feed/ZapReceiptReply.svelte'; + import FeedPost from '../feed/FeedPost.svelte'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; interface Props { - threadId: string; // The kind 11 thread event ID + threadId: string; // The event ID + event?: NostrEvent; // The event itself (optional, used to determine reply types) } - let { threadId }: Props = $props(); + let { threadId, event }: Props = $props(); let comments = $state([]); + let kind1Replies = $state([]); + let yakBacks = $state([]); let zapReceipts = $state([]); let loading = $state(true); let replyingTo = $state(null); + + const isKind1 = $derived(event?.kind === 1); onMount(async () => { await nostrClient.initialize(); @@ -28,29 +34,47 @@ try { const config = nostrClient.getConfig(); const relays = relayManager.getCommentReadRelays(); + const feedRelays = relayManager.getFeedReadRelays(); + const allRelays = [...new Set([...relays, ...feedRelays])]; - // First, fetch comments (kind 1111) that directly reference this thread - // NIP-22: Comments use K tag for kind and E tag for event - const directCommentFilters = [ - { - kinds: [1111], - '#K': ['11'], // Comments on kind 11 threads - '#E': [threadId] // Comments on this specific thread - } + const replyFilters: any[] = [ + { kinds: [9735], '#e': [threadId] }, // Zap receipts + { kinds: [1244], '#e': [threadId] }, // Yak backs (voice replies) ]; - - let directComments = await nostrClient.fetchEvents( - directCommentFilters, - relays, + + // For kind 1 events, also fetch kind 1 replies + if (isKind1) { + replyFilters.push({ kinds: [1], '#e': [threadId] }); + } + + // For all events, fetch kind 1111 comments + // For kind 11 threads, use #E and #K tags (NIP-22) + // For other events, use #e tag + if (event?.kind === 11) { + replyFilters.push( + { kinds: [1111], '#E': [threadId], '#K': ['11'] }, // NIP-22 standard (uppercase) + { kinds: [1111], '#e': [threadId] } // Fallback (lowercase) + ); + } else { + replyFilters.push({ kinds: [1111], '#e': [threadId] }); + } + + const allReplies = await nostrClient.fetchEvents( + replyFilters, + allRelays, { useCache: true, cacheResults: true } ); - - comments = directComments; + + // Separate by type + comments = allReplies.filter(e => e.kind === 1111); + kind1Replies = allReplies.filter(e => e.kind === 1); + yakBacks = allReplies.filter(e => e.kind === 1244); + zapReceipts = allReplies.filter(e => e.kind === 9735); // Recursively fetch all nested replies await fetchNestedReplies(); - // Fetch zap receipts that reference this thread or any comment + // Fetch zap receipts that reference this thread or any comment/reply await fetchZapReceipts(); } catch (error) { console.error('Error loading comments:', error); @@ -61,69 +85,63 @@ async function fetchNestedReplies() { const relays = relayManager.getCommentReadRelays(); - let hasNewComments = true; + const feedRelays = relayManager.getFeedReadRelays(); + const allRelays = [...new Set([...relays, ...feedRelays])]; + let hasNewReplies = true; let iterations = 0; const maxIterations = 10; // Prevent infinite loops // Keep fetching until we have all nested replies - while (hasNewComments && iterations < maxIterations) { + while (hasNewReplies && iterations < maxIterations) { iterations++; - hasNewComments = false; - const allCommentIds = new Set(comments.map(c => c.id)); + hasNewReplies = false; + const allReplyIds = new Set([ + ...comments.map(c => c.id), + ...kind1Replies.map(r => r.id), + ...yakBacks.map(y => y.id) + ]); - if (allCommentIds.size > 0) { - // Fetch comments that reference any comment we have (replies to replies) - const replyToCommentsFilters = [ - { - kinds: [1111], - '#K': ['11'], // Comments on kind 11 threads - '#E': Array.from(allCommentIds) // Comments that reference any of our comments + if (allReplyIds.size > 0) { + const nestedFilters: any[] = [ + { kinds: [9735], '#e': Array.from(allReplyIds) }, // Zap receipts + { kinds: [1244], '#e': Array.from(allReplyIds) }, // Yak backs + ]; + + // For kind 1 events, also fetch nested kind 1 replies + if (isKind1) { + nestedFilters.push({ kinds: [1], '#e': Array.from(allReplyIds) }); } - ]; - - const replyToComments = await nostrClient.fetchEvents( - replyToCommentsFilters, - relays, - { useCache: true, cacheResults: true } - ); - - // Add new comments that are replies to our comments - for (const reply of replyToComments) { - if (!allCommentIds.has(reply.id)) { - comments.push(reply); - hasNewComments = true; + + // Fetch nested comments + if (event?.kind === 11) { + nestedFilters.push( + { kinds: [1111], '#E': Array.from(allReplyIds), '#K': ['11'] }, + { kinds: [1111], '#e': Array.from(allReplyIds) } + ); + } else { + nestedFilters.push({ kinds: [1111], '#e': Array.from(allReplyIds) }); } - } - } - - // Also fetch missing parent comments that are referenced but not loaded - const missingReplyIds = new Set(); - for (const comment of comments) { - const eTag = comment.tags.find((t) => t[0] === 'E') || comment.tags.find((t) => t[0] === 'e' && t[1] !== comment.id); - if (eTag && eTag[1] && eTag[1] !== threadId) { - const parentExists = comments.some(c => c.id === eTag[1]); - if (!parentExists) { - missingReplyIds.add(eTag[1]); + + const nestedReplies = await nostrClient.fetchEvents( + nestedFilters, + allRelays, + { useCache: true, cacheResults: true } + ); + + // Add new replies by type + for (const reply of nestedReplies) { + if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) { + comments.push(reply); + hasNewReplies = true; + } else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) { + kind1Replies.push(reply); + hasNewReplies = true; + } else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) { + yakBacks.push(reply); + hasNewReplies = true; } } } - - if (missingReplyIds.size > 0) { - const replyComments = await nostrClient.fetchEvents( - [{ kinds: [1111], ids: Array.from(missingReplyIds) }], - relays, - { useCache: true, cacheResults: true } - ); - - // Add new parent comments - for (const reply of replyComments) { - const exists = comments.some(c => c.id === reply.id); - if (!exists) { - comments.push(reply); - hasNewComments = true; - } - } - } } } @@ -131,24 +149,32 @@ async function fetchZapReceipts() { const config = nostrClient.getConfig(); const relays = relayManager.getCommentReadRelays(); + const feedRelays = relayManager.getFeedReadRelays(); + const allRelays = [...new Set([...relays, ...feedRelays])]; // Keep fetching until we have all zaps let previousCount = -1; while (zapReceipts.length !== previousCount) { previousCount = zapReceipts.length; - const allEventIds = new Set([threadId, ...comments.map(c => c.id), ...zapReceipts.map(z => z.id)]); + const allEventIds = new Set([ + threadId, + ...comments.map(c => c.id), + ...kind1Replies.map(r => r.id), + ...yakBacks.map(y => y.id), + ...zapReceipts.map(z => z.id) + ]); - // Fetch zap receipts that reference thread or any comment/zap + // Fetch zap receipts that reference thread or any comment/reply/yak/zap const zapFilters = [ { kinds: [9735], - '#e': Array.from(allEventIds) // Zap receipts for thread and all comments/zaps + '#e': Array.from(allEventIds) // Zap receipts for thread and all replies } ]; const zapEvents = await nostrClient.fetchEvents( zapFilters, - relays, + allRelays, { useCache: true, cacheResults: true } ); @@ -170,43 +196,54 @@ } } - // Check if any zaps reference comments we don't have - const missingCommentIds = new Set(); + // Check if any zaps reference events we don't have + const missingEventIds = new Set(); for (const zap of validZaps) { const eTag = zap.tags.find((t) => t[0] === 'e'); if (eTag && eTag[1] && eTag[1] !== threadId) { - if (!comments.some(c => c.id === eTag[1])) { - missingCommentIds.add(eTag[1]); + const exists = comments.some(c => c.id === eTag[1]) + || kind1Replies.some(r => r.id === eTag[1]) + || yakBacks.some(y => y.id === eTag[1]); + if (!exists) { + missingEventIds.add(eTag[1]); } } } - // Fetch missing comments - if (missingCommentIds.size > 0) { - const missingComments = await nostrClient.fetchEvents( - [{ kinds: [1111], ids: Array.from(missingCommentIds) }], - relays, + // Fetch missing events (could be comments, replies, or yak backs) + if (missingEventIds.size > 0) { + const missingEvents = await nostrClient.fetchEvents( + [ + { kinds: [1111], ids: Array.from(missingEventIds) }, + { kinds: [1], ids: Array.from(missingEventIds) }, + { kinds: [1244], ids: Array.from(missingEventIds) } + ], + allRelays, { useCache: true, cacheResults: true } ); - for (const comment of missingComments) { - if (!comments.some(c => c.id === comment.id)) { - comments.push(comment); + for (const event of missingEvents) { + if (event.kind === 1111 && !comments.some(c => c.id === event.id)) { + comments.push(event); + } else if (event.kind === 1 && !kind1Replies.some(r => r.id === event.id)) { + kind1Replies.push(event); + } else if (event.kind === 1244 && !yakBacks.some(y => y.id === event.id)) { + yakBacks.push(event); } } - // Fetch nested replies to newly found comments + // Fetch nested replies to newly found events await fetchNestedReplies(); } } } - function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'zap' }> { + function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> { // Build thread structure similar to feed - const eventMap = new Map(); + const eventMap = new Map(); const replyMap = new Map(); // parentId -> childIds[] - const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = []; + const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = []; const allEventIds = new Set(); // First pass: build event map and collect all event IDs @@ -217,19 +254,19 @@ // Second pass: determine parent-child relationships for (const item of items) { - // Check if this is a reply + // Check if this is a reply - check both uppercase E (NIP-22) and lowercase e tags const eTag = item.event.tags.find((t) => t[0] === 'E') || item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id); const parentId = eTag?.[1]; if (parentId) { - // Check if parent is the thread or another comment/zap we have + // Check if parent is the thread or another reply we have if (parentId === threadId || allEventIds.has(parentId)) { - // This is a reply - if (!replyMap.has(parentId)) { - replyMap.set(parentId, []); - } - replyMap.get(parentId)!.push(item.event.id); - } else { + // This is a reply + if (!replyMap.has(parentId)) { + replyMap.set(parentId, []); + } + replyMap.get(parentId)!.push(item.event.id); + } else { // Parent not found - treat as root item (might be a missing parent) rootItems.push(item); } @@ -240,10 +277,10 @@ } // Third pass: recursively collect all items in thread order - const result: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = []; + const result: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = []; const processed = new Set(); - function addThread(item: { event: NostrEvent; type: 'comment' | 'zap' }) { + function addThread(item: { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }) { if (processed.has(item.event.id)) return; processed.add(item.event.id); @@ -253,7 +290,7 @@ const replies = replyMap.get(item.event.id) || []; const replyItems = replies .map(id => eventMap.get(id)) - .filter((item): item is { event: NostrEvent; type: 'comment' | 'zap' } => item !== undefined) + .filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined) .sort((a, b) => a.event.created_at - b.event.created_at); // Sort replies chronologically for (const reply of replyItems) { @@ -270,9 +307,11 @@ return result; } - function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'zap' }> { - const items: Array<{ event: NostrEvent; type: 'comment' | 'zap' }> = [ + function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> { + const items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [ ...comments.map(c => ({ event: c, type: 'comment' as const })), + ...kind1Replies.map(r => ({ event: r, type: 'reply' as const })), + ...yakBacks.map(y => ({ event: y, type: 'yak' as const })), ...zapReceipts.map(z => ({ event: z, type: 'zap' as const })) ]; return sortThreadItems(items); @@ -282,8 +321,11 @@ // NIP-22: E tag (uppercase) points to parent event, or lowercase e tag const eTag = event.tags.find((t) => t[0] === 'E') || event.tags.find((t) => t[0] === 'e' && t[1] !== event.id); if (eTag && eTag[1]) { - // Find parent in comments or zap receipts - const parent = comments.find((c) => c.id === eTag[1]) || zapReceipts.find((z) => z.id === eTag[1]); + // Find parent in comments, replies, yak backs, or zap receipts + const parent = comments.find((c) => c.id === eTag[1]) + || kind1Replies.find((r) => r.id === eTag[1]) + || yakBacks.find((y) => y.id === eTag[1]) + || zapReceipts.find((z) => z.id === eTag[1]); if (parent) return parent; // If parent not found, it might be the thread itself @@ -307,8 +349,8 @@ {#if loading} Loading comments... - {:else if comments.length === 0 && zapReceipts.length === 0} - No comments yet. Be the first to comment! + {:else if comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0 && zapReceipts.length === 0} + No replies yet. Be the first to reply! {:else} {#each getThreadItems() as item (item.event.id)} @@ -319,6 +361,16 @@ parentEvent={parent} onReply={handleReply} /> + {:else if item.type === 'reply'} + + + + + {:else if item.type === 'yak'} + + + + {:else if item.type === 'zap'} import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; + import MediaAttachments from '../../components/content/MediaAttachments.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte'; import QuotedContext from '../../components/content/QuotedContext.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; @@ -222,6 +223,7 @@ + diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index cbfb6a1..bf236e0 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -6,6 +6,7 @@ import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; + import { sessionManager } from '../../services/auth/session-manager.js'; import { onMount } from 'svelte'; import { page } from '$app/stores'; import { nip19 } from 'nostr-tools'; @@ -15,8 +16,80 @@ let userStatus = $state(null); let posts = $state([]); let responses = $state([]); + let interactionsWithMe = $state([]); let loading = $state(true); - let activeTab = $state<'posts' | 'responses'>('posts'); + let activeTab = $state<'posts' | 'responses' | 'interactions'>('posts'); + let nip05Validations = $state>(new Map()); // null = checking, true = valid, false = invalid + + // Get current logged-in user's pubkey + let currentUserPubkey = $state(sessionManager.getCurrentPubkey()); + + // Subscribe to session changes + $effect(() => { + const unsubscribe = sessionManager.session.subscribe((session) => { + currentUserPubkey = session?.pubkey || null; + // Reload interactions if session changes and we're viewing another user's profile + if (profile) { + const pubkey = decodePubkey($page.params.pubkey); + if (pubkey && currentUserPubkey && currentUserPubkey !== pubkey) { + // Reload interactions tab data + loadInteractionsWithMe(pubkey, currentUserPubkey); + } else { + interactionsWithMe = []; + } + } + }); + return unsubscribe; + }); + + async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) { + if (!currentUserPubkey || currentUserPubkey === profilePubkey) { + interactionsWithMe = []; + return; + } + + try { + const interactionRelays = relayManager.getFeedResponseReadRelays(); + // Fetch current user's posts to find replies + const currentUserPosts = await nostrClient.fetchEvents( + [{ kinds: [1], authors: [currentUserPubkey], limit: 50 }], + interactionRelays, + { useCache: true, cacheResults: true } + ); + const currentUserPostIds = new Set(currentUserPosts.map(p => p.id)); + + const interactionEvents = await nostrClient.fetchEvents( + [ + { kinds: [1], authors: [profilePubkey], '#e': Array.from(currentUserPostIds), limit: 20 }, // Replies to current user's posts + { kinds: [1], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } // Mentions of current user + ], + interactionRelays, + { useCache: true, cacheResults: true } + ); + + // Deduplicate and filter to only include actual interactions + const seenIds = new Set(); + interactionsWithMe = interactionEvents + .filter(e => { + if (seenIds.has(e.id)) return false; + seenIds.add(e.id); + + // Check if it's a reply to current user's post + const eTag = e.tags.find(t => t[0] === 'e'); + const isReplyToCurrentUser = eTag && currentUserPostIds.has(eTag[1]); + + // Check if it mentions current user + const pTag = e.tags.find(t => t[0] === 'p' && t[1] === currentUserPubkey); + const mentionsCurrentUser = !!pTag; + + return isReplyToCurrentUser || mentionsCurrentUser; + }) + .sort((a, b) => b.created_at - a.created_at); + } catch (error) { + console.error('Error loading interactions with me:', error); + interactionsWithMe = []; + } + } onMount(async () => { await nostrClient.initialize(); @@ -74,6 +147,54 @@ return null; } + /** + * Validate NIP-05 address against well-known.json + */ + async function validateNIP05(nip05: string, expectedPubkey: string) { + // Mark as checking + nip05Validations.set(nip05, null); + + try { + // Parse NIP-05: format is "local@domain.com" + const [localPart, domain] = nip05.split('@'); + if (!localPart || !domain) { + nip05Validations.set(nip05, false); + return; + } + + // Fetch well-known JSON + // NIP-05 spec: https://[domain]/.well-known/nostr.json?name=[local] + const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + nip05Validations.set(nip05, false); + return; + } + + const data = await response.json(); + + // Check if the response contains the expected pubkey + // NIP-05 format: { "names": { "local": "hex-pubkey" } } + if (data.names && data.names[localPart]) { + const verifiedPubkey = data.names[localPart].toLowerCase(); + const expected = expectedPubkey.toLowerCase(); + nip05Validations.set(nip05, verifiedPubkey === expected); + } else { + nip05Validations.set(nip05, false); + } + } catch (error) { + console.error('Error validating NIP-05:', nip05, error); + nip05Validations.set(nip05, false); + } + } + async function loadProfile() { const param = $page.params.pubkey; if (!param) { @@ -101,6 +222,16 @@ profile = profileData; console.log('Profile loaded:', profileData); + // Validate NIP-05 addresses (async, don't wait) + if (profileData?.nip05 && profileData.nip05.length > 0) { + for (const nip05 of profileData.nip05) { + // Validate in background - don't block page load + validateNIP05(nip05, pubkey).catch(err => { + console.error('NIP-05 validation error:', err); + }); + } + } + // Load user status const status = await fetchUserStatus(pubkey); userStatus = status; @@ -122,13 +253,25 @@ { useCache: true, cacheResults: true } ); // Filter to only include actual replies (have e tag pointing to user's posts) + // AND exclude self-replies (where author is the same as the profile owner) const userPostIds = new Set(posts.map(p => p.id)); responses = responseEvents .filter(e => { + // Exclude self-replies + if (e.pubkey === pubkey) { + return false; + } const eTag = e.tags.find(t => t[0] === 'e'); return eTag && userPostIds.has(eTag[1]); }) .sort((a, b) => b.created_at - a.created_at); + + // Load "Interactions with me" if user is logged in and viewing another user's profile + if (currentUserPubkey && currentUserPubkey !== pubkey) { + await loadInteractionsWithMe(pubkey, currentUserPubkey); + } else { + interactionsWithMe = []; + } } catch (error) { console.error('Error loading profile:', error); // Set loading to false even on error so UI can show error state @@ -181,8 +324,16 @@ {#if profile.nip05 && profile.nip05.length > 0} {#each profile.nip05 as nip05} - + {@const isValid = nip05Validations.get(nip05)} + {nip05} + {#if isValid === true} + ✓ + {:else if isValid === false} + ✗ + {:else} + ⟳ + {/if} {/each} @@ -204,6 +355,14 @@ > Responses ({responses.length}) + {#if currentUserPubkey && currentUserPubkey !== decodePubkey($page.params.pubkey)} + activeTab = 'interactions'} + class="px-4 py-2 font-semibold {activeTab === 'interactions' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" + > + Interactions with me ({interactionsWithMe.length}) + + {/if} {#if activeTab === 'posts'} @@ -216,7 +375,7 @@ {/each} {/if} - {:else} + {:else if activeTab === 'responses'} {#if responses.length === 0} No responses yet. {:else} @@ -226,6 +385,16 @@ {/each} {/if} + {:else if activeTab === 'interactions'} + {#if interactionsWithMe.length === 0} + No interactions with you yet. + {:else} + + {#each interactionsWithMe as interaction (interaction.id)} + + {/each} + + {/if} {/if} {:else} @@ -243,4 +412,32 @@ .profile-picture { object-fit: cover; } + + .nip05-valid { + color: #60a5fa; + margin-left: 0.25rem; + font-weight: bold; + } + + .nip05-invalid { + color: #ef4444; + margin-left: 0.25rem; + font-weight: bold; + } + + .nip05-checking { + color: #9ca3af; + margin-left: 0.25rem; + display: inline-block; + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } diff --git a/src/lib/modules/threads/ThreadView.svelte b/src/lib/modules/threads/ThreadView.svelte index 3b2d12f..14f4512 100644 --- a/src/lib/modules/threads/ThreadView.svelte +++ b/src/lib/modules/threads/ThreadView.svelte @@ -38,8 +38,12 @@ async function loadThread() { loading = true; try { - const relays = relayManager.getThreadReadRelays(); - const event = await nostrClient.getEventById(threadId, relays); + // Try multiple relay sets to find the event + const threadRelays = relayManager.getThreadReadRelays(); + const feedRelays = relayManager.getFeedReadRelays(); + const allRelays = [...new Set([...threadRelays, ...feedRelays])]; + + const event = await nostrClient.getEventById(threadId, allRelays); thread = event; } catch (error) { console.error('Error loading thread:', error); @@ -50,8 +54,17 @@ function getTitle(): string { if (!thread) return ''; - const titleTag = thread.tags.find((t) => t[0] === 'title'); - return titleTag?.[1] || 'Untitled'; + // For kind 11 threads, use title tag + if (thread.kind === 11) { + const titleTag = thread.tags.find((t) => t[0] === 'title'); + return titleTag?.[1] || 'Untitled'; + } + // For other kinds, show kind description or first line of content + const firstLine = thread.content.split('\n')[0].trim(); + if (firstLine.length > 0 && firstLine.length < 100) { + return firstLine; + } + return getKindInfo(thread.kind).description; } function getTopics(): string[] { @@ -150,7 +163,7 @@ {/if} - + diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index dac8124..3c44f10 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -4,18 +4,76 @@ import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; import { onMount } from 'svelte'; import { page } from '$app/stores'; + import { nip19 } from 'nostr-tools'; + + let decodedEventId = $state(null); + + /** + * Decode route parameter to event hex ID + * Supports: hex event id, note, nevent, naddr + */ + function decodeEventId(param: string): string | null { + if (!param) return null; + + // Check if it's already a hex event ID (64 hex characters) + if (/^[0-9a-f]{64}$/i.test(param)) { + return param.toLowerCase(); + } + + // Check if it's a bech32 encoded format (note, nevent, naddr) + if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(param)) { + try { + const decoded = nip19.decode(param); + if (decoded.type === 'note') { + return String(decoded.data); + } else if (decoded.type === 'nevent') { + // nevent contains event id and optional relays + if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { + return String(decoded.data.id); + } + } else if (decoded.type === 'naddr') { + // naddr is for parameterized replaceable events (kind + pubkey + d tag) + // We need to fetch the event using these parameters, then get its id + // For now, return a special marker that ThreadView can handle + // naddr format: { kind, pubkey, identifier (d tag), relays? } + if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data) { + // Store naddr data for fetching - we'll handle this in ThreadView + // For now, return null and ThreadView will need to fetch by kind+pubkey+d + console.warn('naddr requires fetching event by kind+pubkey+d, not yet fully supported'); + return null; + } + } + } catch (error) { + console.error('Error decoding bech32:', error); + return null; + } + } + + return null; + } onMount(async () => { await nostrClient.initialize(); + if ($page.params.id) { + decodedEventId = decodeEventId($page.params.id); + } + }); + + $effect(() => { + if ($page.params.id) { + decodedEventId = decodeEventId($page.params.id); + } }); - {#if $page.params.id} - + {#if decodedEventId} + + {:else if $page.params.id} + Invalid event ID format. Supported: hex event ID, note, nevent, or naddr {:else} - Thread ID required + Event ID required {/if}
Loading comments...
No comments yet. Be the first to comment!
No replies yet. Be the first to reply!
No responses yet.
No interactions with you yet.
Invalid event ID format. Supported: hex event ID, note, nevent, or naddr
Thread ID required
Event ID required